From 3e0d51dfa0a7fc195e999db3fb37d25f0b11ab70 Mon Sep 17 00:00:00 2001 From: Colin Bellmore Date: Tue, 28 Jan 2025 21:18:16 -0800 Subject: [PATCH 1/2] feat: support multiple passkey per domain This is a brute force approach from the contract side only, the more elegant side would be to use the authenticationResponse.id which would require persisting it and breaking the signature format! --- src/validators/WebAuthValidator.sol | 84 +++++++++++++++++++++-------- test/PasskeyModule.ts | 75 +++++++++++++------------- 2 files changed, 99 insertions(+), 60 deletions(-) diff --git a/src/validators/WebAuthValidator.sol b/src/validators/WebAuthValidator.sol index b8609988..1c645097 100644 --- a/src/validators/WebAuthValidator.sol +++ b/src/validators/WebAuthValidator.sol @@ -25,10 +25,13 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator { bytes32 private constant HIGH_R_MAX = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551; event PasskeyCreated(address indexed keyOwner, string originDomain); + event PasskeyRemoved(address indexed keyOwner, string originDomain); // 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(uint8 index => mapping(address accountAddress => bytes32 keyBytes) keyByteMapping) keyIndexMapping) + public lowerKeyHalf; + mapping(string originDomain => mapping(uint8 index => mapping(address accountAddress => bytes32 keyBytes) keyByteMapping) keyIndexMapping) + public upperKeyHalf; /// @notice Runs on module install /// @param data ABI-encoded WebAuthn passkey to add immediately, or empty if not needed @@ -44,8 +47,8 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator { 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; + lowerKeyHalf[domain][0][msg.sender] = 0x0; + upperKeyHalf[domain][0][msg.sender] = 0x0; } } @@ -54,19 +57,44 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator { /// @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]; - - // we might want to support multiple passkeys per domain - lowerKeyHalf[originDomain][msg.sender] = key32[0]; - upperKeyHalf[originDomain][msg.sender] = key32[1]; + bool keyAdded = false; + for (uint8 index = 0; index < 255; index++) { + bytes32 initialLowerHalf = lowerKeyHalf[originDomain][index][msg.sender]; + bytes32 initialUpperHalf = upperKeyHalf[originDomain][index][msg.sender]; + bool keyExists = initialLowerHalf != 0 && initialUpperHalf != 0; + if (keyExists) { + continue; + } - // we're returning true if this was a new key, false for update - bool keyExists = initialLowerHalf == 0 && initialUpperHalf == 0; + lowerKeyHalf[originDomain][index][msg.sender] = key32[0]; + upperKeyHalf[originDomain][index][msg.sender] = key32[1]; + keyAdded = true; + break; + } emit PasskeyCreated(msg.sender, originDomain); - return keyExists; + return keyAdded; + } + + /// @notice Removes a WebAuthn passkey for the caller + /// @param originDomain string domain to remove the key from + /// @param index domain to remove the key from + /// @return true if the key was removed, false if it failed + function removeValidationKey(string calldata originDomain, uint8 index) public returns (bool) { + bytes32 initialLowerHalf = lowerKeyHalf[originDomain][index][msg.sender]; + bytes32 initialUpperHalf = upperKeyHalf[originDomain][index][msg.sender]; + bool noKey = initialLowerHalf == 0 && initialUpperHalf == 0; + if (noKey) { + return false; + } + + lowerKeyHalf[originDomain][index][msg.sender] = 0x0; + upperKeyHalf[originDomain][index][msg.sender] = 0x0; + + emit PasskeyRemoved(msg.sender, originDomain); + + return true; } /// @notice Validates a WebAuthn signature @@ -128,15 +156,6 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator { return false; } - string memory origin = root.at('"origin"').value().decodeString(); - bytes32[2] memory pubkey; - pubkey[0] = lowerKeyHalf[origin][msg.sender]; - pubkey[1] = upperKeyHalf[origin][msg.sender]; - // This really only validates the origin is set - if (pubkey[0] == 0 || pubkey[1] == 0) { - return false; - } - JSONParserLib.Item memory crossOriginItem = root.at('"crossOrigin"'); if (!crossOriginItem.isUndefined()) { string memory crossOrigin = crossOriginItem.value(); @@ -145,8 +164,27 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator { } } + string memory origin = root.at('"origin"').value().decodeString(); + bytes32[2] memory pubkey; bytes32 message = _createMessage(authenticatorData, bytes(clientDataJSON)); - return callVerifier(P256_VERIFIER, message, rs, pubkey); + + // try all possible passkeys for this domain + for (uint8 index = 0; index < 255; index++) { + pubkey[0] = lowerKeyHalf[origin][0][msg.sender]; + pubkey[1] = upperKeyHalf[origin][0][msg.sender]; + // This really only validates the origin is set + if (pubkey[0] == 0 || pubkey[1] == 0) { + continue; + } + + bool keyVerified = callVerifier(P256_VERIFIER, message, rs, pubkey); + if (keyVerified) { + return true; + } + } + + // no keys matched + return false; } /// @inheritdoc IERC165 diff --git a/test/PasskeyModule.ts b/test/PasskeyModule.ts index ed6d4b7b..c143c0e6 100644 --- a/test/PasskeyModule.ts +++ b/test/PasskeyModule.ts @@ -5,15 +5,15 @@ import { ECDSASigValue } from "@peculiar/asn1-ecc"; import { AsnParser } from "@peculiar/asn1-schema"; import { bigintToBuf, bufToBigint } from "bigint-conversion"; import { assert, expect } from "chai"; +import { randomBytes } from "crypto"; +import { parseEther, ZeroAddress } from "ethers"; import * as hre from "hardhat"; +import { encodeAbiParameters, Hex, hexToBytes, toHex } from "viem"; import { SmartAccount, Wallet } from "zksync-ethers"; +import { base64UrlToUint8Array } from "zksync-sso/utils"; import { SsoAccount__factory, WebAuthValidator, WebAuthValidator__factory } from "../typechain-types"; import { ContractFixtures, getProvider, getWallet, LOCAL_RICH_WALLETS, logInfo, RecordedResponse } from "./utils"; -import { base64UrlToUint8Array } from "zksync-sso/utils"; -import { encodeAbiParameters, Hex, hexToBytes, toHex } from "viem"; -import { randomBytes } from "crypto"; -import { parseEther, ZeroAddress } from "ethers"; /** * Decode from a Base64URL-encoded string to an ArrayBuffer. Best used when converting a @@ -376,12 +376,13 @@ async function rawVerify( async function verifyKeyStorage( passkeyValidator: WebAuthValidator, domain: string, + index: number, publicKeys, wallet: Wallet, error: string, ) { - const lowerKey = await passkeyValidator.lowerKeyHalf(domain, wallet.address); - const upperKey = await passkeyValidator.upperKeyHalf(domain, wallet.address); + const lowerKey = await passkeyValidator.lowerKeyHalf(domain, index, wallet.address); + const upperKey = await passkeyValidator.upperKeyHalf(domain, index, wallet.address); expect(lowerKey).to.eq(publicKeys[0], `lower key ${error}`); expect(upperKey).to.eq(publicKeys[1], `upper key ${error}`); } @@ -390,11 +391,11 @@ function encodeKeyFromHex(hexStrings: [Hex, Hex], domain: string) { // the same as the ethers: new AbiCoder().encode(["bytes32[2]", "string"], [bytes, domain]); return encodeAbiParameters( [ - { name: 'publicKeys', type: 'bytes32[2]' }, - { name: 'domain', type: 'string' }, + { name: "publicKeys", type: "bytes32[2]" }, + { name: "domain", type: "string" }, ], - [[hexStrings[0], hexStrings[1]], domain] - ) + [[hexStrings[0], hexStrings[1]], domain], + ); } function encodeKeyFromBytes(bytes: [Uint8Array, Uint8Array], domain: string) { @@ -428,8 +429,8 @@ async function validateSignatureTest( { name: "clientDataJson", type: "string" }, { name: "rs", type: "bytes32[2]" }, ], - [toHex(authData), sampleClientString, [toHex(rNormalization(generatedSignature.r)), toHex(sNormalization(generatedSignature.s))]] - ) + [toHex(authData), sampleClientString, [toHex(rNormalization(generatedSignature.r)), toHex(sNormalization(generatedSignature.s))]], + ); return await passkeyValidator.validateSignature(transactionHash, fatSignature); } @@ -480,7 +481,7 @@ describe("Passkey validation", function () { const receipt = await fundTx.wait(); expect(receipt.status).to.eq(1, "send funds to proxy account"); - return { passKeyModuleContract, sampleDomain, proxyAccountAddress, generatedR1Key, passKeyModuleAddress } + return { passKeyModuleContract, sampleDomain, proxyAccountAddress, generatedR1Key, passKeyModuleAddress }; } it("should deploy proxy account via factory", async () => { @@ -488,9 +489,9 @@ describe("Passkey validation", function () { const [generatedX, generatedY] = await getRawPublicKeyFromCrpyto(generatedR1Key); - const initLowerKey = await passKeyModuleContract.lowerKeyHalf(sampleDomain, proxyAccountAddress); + const initLowerKey = await passKeyModuleContract.lowerKeyHalf(sampleDomain, 0, proxyAccountAddress); expect(initLowerKey).to.equal(toHex(generatedX), "initial lower key should exist"); - const initUpperKey = await passKeyModuleContract.upperKeyHalf(sampleDomain, proxyAccountAddress); + const initUpperKey = await passKeyModuleContract.upperKeyHalf(sampleDomain, 0, proxyAccountAddress); expect(initUpperKey).to.equal(toHex(generatedY), "initial upper key should exist"); const account = SsoAccount__factory.connect(proxyAccountAddress, provider); @@ -523,8 +524,8 @@ describe("Passkey validation", function () { ], [ toHex(authData), sampleClientString, - [toHex(normalizeR(generatedSignature.r)), toHex(normalizeS(generatedSignature.s))] - ]) + [toHex(normalizeR(generatedSignature.r)), toHex(normalizeS(generatedSignature.s))], + ]); const moduleSignature = encodeAbiParameters( [{ name: "signature", type: "bytes" }, { name: "moduleAddress", type: "address" }, { name: "validatorData", type: "bytes" }], @@ -532,7 +533,7 @@ describe("Passkey validation", function () { return moduleSignature; }, address: proxyAccountAddress, - secret: wallet.privateKey, //generatedR1Key.privateKey, + secret: wallet.privateKey, // generatedR1Key.privateKey, }, provider); const aaTransaction = { @@ -592,11 +593,11 @@ describe("Passkey validation", function () { const keyReceipt = await secondCreatedKey.wait(); assert(keyReceipt?.status == 1, "second key was saved"); - await verifyKeyStorage(passkeyValidator, firstDomain, publicKeys, wallet, "first domain"); - await verifyKeyStorage(passkeyValidator, secondDomain, publicKeys, wallet, "second domain"); + await verifyKeyStorage(passkeyValidator, firstDomain, 0, publicKeys, wallet, "first domain"); + await verifyKeyStorage(passkeyValidator, secondDomain, 0, publicKeys, wallet, "second domain"); }); - it("should update existing key", async () => { + it("should add second key to the same domain", async () => { const passkeyValidator = await deployValidator(wallet); const keyDomain = randomBytes(32).toString("hex"); const generatedR1Key = await generateES256R1Key(); @@ -607,7 +608,7 @@ describe("Passkey validation", function () { const receipt = await generatedKeyAdded.wait(); assert(receipt?.status == 1, "generated key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, [toHex(generatedX), toHex(generatedY)], wallet, "first key"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet, "first key"); const nextR1Key = await generateES256R1Key(); assert(nextR1Key != null, "no second key was generated"); @@ -617,7 +618,8 @@ describe("Passkey validation", function () { const newReceipt = await nextKeyAdded.wait(); assert(newReceipt?.status == 1, "new generated key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, [toHex(newX), toHex(newY)], wallet, "updated key"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet, "first key"); + await verifyKeyStorage(passkeyValidator, keyDomain, 1, [toHex(newX), toHex(newY)], wallet, "updated key"); }); it("should allow clearing existing key", async () => { @@ -630,15 +632,14 @@ describe("Passkey validation", function () { const generatedKeyAdded = await passkeyValidator.addValidationKey(generatedKey); const receipt = await generatedKeyAdded.wait(); assert(receipt?.status == 1, "generated key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, [toHex(generatedX), toHex(generatedY)], wallet, "added"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet, "added"); const zeroKey = new Uint8Array(32).fill(0); - const emptyKey = encodeKeyFromBytes([zeroKey, zeroKey], keyDomain); - const emptyKeyAdded = await passkeyValidator.addValidationKey(emptyKey); + const emptyKeyAdded = await passkeyValidator.removeValidationKey(keyDomain, 0); const emptyReceipt = await emptyKeyAdded.wait(); assert(emptyReceipt?.status == 1, "empty key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, [toHex(zeroKey), toHex(zeroKey)], wallet, "key removed"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(zeroKey), toHex(zeroKey)], wallet, "key removed"); }); }); @@ -675,10 +676,10 @@ describe("Passkey validation", function () { // 37 bytes const authenticatorData = "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAABQ"; - const clientData = - "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZFhPM3ctdWdycS00SkdkZUJLNDFsZFk1V2lNd0ZORDkiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUxNzMiLCJjcm9zc09yaWdpbiI6ZmFsc2UsIm90aGVyX2tleXNfY2FuX2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgifQ"; - const b64SignedChallenge = - "MEUCIQCYrSUCR_QUPAhvRNUVfYiJC2JlOKuqf4gx7i129n9QxgIgaY19A9vAAObuTQNs5_V9kZFizwRpUFpiRVW_dglpR2A"; + const clientData + = "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZFhPM3ctdWdycS00SkdkZUJLNDFsZFk1V2lNd0ZORDkiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjUxNzMiLCJjcm9zc09yaWdpbiI6ZmFsc2UsIm90aGVyX2tleXNfY2FuX2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgifQ"; + const b64SignedChallenge + = "MEUCIQCYrSUCR_QUPAhvRNUVfYiJC2JlOKuqf4gx7i129n9QxgIgaY19A9vAAObuTQNs5_V9kZFizwRpUFpiRVW_dglpR2A"; const verifyMessage = await rawVerify( passkeyValidator, @@ -757,8 +758,8 @@ describe("Passkey validation", function () { it("should fail when signature is bad", async function () { const passkeyValidator = await deployValidator(wallet); - const b64SignedChallenge = - "MEUCIQCYrSUCR_QUPAhvRNUVfYiJC2JlOKuqf4gx7i129n9QxgIgaY19A9vAAObuTQNs5_V9kZFizwRpUFpiRVW_dglpR2A"; + const b64SignedChallenge + = "MEUCIQCYrSUCR_QUPAhvRNUVfYiJC2JlOKuqf4gx7i129n9QxgIgaY19A9vAAObuTQNs5_V9kZFizwRpUFpiRVW_dglpR2A"; const verifyMessage = await rawVerify( passkeyValidator, ethersResponse.authenticatorData, @@ -899,10 +900,10 @@ describe("Passkey validation", function () { const partialClientObject = { challenge: "jBBiiOGt1aSBy1WAuRGxqU7YzRM5oWpMA9g8MKydjPI", }; - const duplicatedClientString = - JSON.stringify(sampleClientObject).slice(0, -1) + - "," + - JSON.stringify(partialClientObject).slice(1); + const duplicatedClientString + = JSON.stringify(sampleClientObject).slice(0, -1) + + "," + + JSON.stringify(partialClientObject).slice(1); const authData = toBuffer(ethersResponse.authenticatorData); const transactionHash = Buffer.from(sampleClientObject.challenge, "base64url"); const isValidSignature = await validateSignatureTest( From 88440db910b2a458b9a24ca3f3a8c0f072bc1b37 Mon Sep 17 00:00:00 2001 From: Colin Bellmore Date: Tue, 28 Jan 2025 23:04:02 -0800 Subject: [PATCH 2/2] feat: add more tests for multi-passkey signing Fixes an issue with validation --- src/validators/WebAuthValidator.sol | 4 +- test/PasskeyModule.ts | 205 +++++++++++++++++++--------- test/utils.ts | 26 ++-- 3 files changed, 151 insertions(+), 84 deletions(-) diff --git a/src/validators/WebAuthValidator.sol b/src/validators/WebAuthValidator.sol index 1c645097..ee8a0133 100644 --- a/src/validators/WebAuthValidator.sol +++ b/src/validators/WebAuthValidator.sol @@ -170,8 +170,8 @@ contract WebAuthValidator is VerifierCaller, IModuleValidator { // try all possible passkeys for this domain for (uint8 index = 0; index < 255; index++) { - pubkey[0] = lowerKeyHalf[origin][0][msg.sender]; - pubkey[1] = upperKeyHalf[origin][0][msg.sender]; + pubkey[0] = lowerKeyHalf[origin][index][msg.sender]; + pubkey[1] = upperKeyHalf[origin][index][msg.sender]; // This really only validates the origin is set if (pubkey[0] == 0 || pubkey[1] == 0) { continue; diff --git a/test/PasskeyModule.ts b/test/PasskeyModule.ts index c143c0e6..18108454 100644 --- a/test/PasskeyModule.ts +++ b/test/PasskeyModule.ts @@ -9,7 +9,7 @@ import { randomBytes } from "crypto"; import { parseEther, ZeroAddress } from "ethers"; import * as hre from "hardhat"; import { encodeAbiParameters, Hex, hexToBytes, toHex } from "viem"; -import { SmartAccount, Wallet } from "zksync-ethers"; +import { Provider, SmartAccount, Wallet } from "zksync-ethers"; import { base64UrlToUint8Array } from "zksync-sso/utils"; import { SsoAccount__factory, WebAuthValidator, WebAuthValidator__factory } from "../typechain-types"; @@ -337,11 +337,7 @@ function encodeFatSignature( authenticatorData: string; clientDataJSON: string; signature: string; - }, - contracts: { - passkey: string; - }, -) { + }) { const signature = unwrapEC2Signature(base64UrlToUint8Array(passkeyResponse.signature)); return encodeAbiParameters( [ @@ -378,11 +374,11 @@ async function verifyKeyStorage( domain: string, index: number, publicKeys, - wallet: Wallet, + accountAddress: string, error: string, ) { - const lowerKey = await passkeyValidator.lowerKeyHalf(domain, index, wallet.address); - const upperKey = await passkeyValidator.upperKeyHalf(domain, index, wallet.address); + const lowerKey = await passkeyValidator.lowerKeyHalf(domain, index, accountAddress); + const upperKey = await passkeyValidator.upperKeyHalf(domain, index, accountAddress); expect(lowerKey).to.eq(publicKeys[0], `lower key ${error}`); expect(upperKey).to.eq(publicKeys[1], `upper key ${error}`); } @@ -402,6 +398,19 @@ function encodeKeyFromBytes(bytes: [Uint8Array, Uint8Array], domain: string) { return encodeKeyFromHex([toHex(bytes[0]), toHex(bytes[1])], domain); } +async function addNewWebAuthnKey(wallet: Wallet, keyDomain: string) { + const passkeyValidator = await deployValidator(wallet); + const generatedR1Key = await generateES256R1Key(); + assert(generatedR1Key != null, "no key was generated"); + const [generatedX, generatedY] = await getRawPublicKeyFromCrpyto(generatedR1Key); + const generatedKey = encodeKeyFromBytes([generatedX, generatedY], keyDomain); + const addingKey = await passkeyValidator.addValidationKey(generatedKey); + const addingKeyResult = await addingKey.wait(); + expect(addingKeyResult?.status).to.eq(1, "failed to add key during setup"); + + return { generatedR1Key, passkeyValidator }; +} + async function validateSignatureTest( wallet: Wallet, keyDomain: string, @@ -411,14 +420,7 @@ async function validateSignatureTest( sampleClientString: string, transactionHash: Buffer, ) { - const passkeyValidator = await deployValidator(wallet); - const generatedR1Key = await generateES256R1Key(); - assert(generatedR1Key != null, "no key was generated"); - const [generatedX, generatedY] = await getRawPublicKeyFromCrpyto(generatedR1Key); - const generatedKey = encodeKeyFromBytes([generatedX, generatedY], keyDomain); - const addingKey = await passkeyValidator.addValidationKey(generatedKey); - const addingKeyResult = await addingKey.wait(); - expect(addingKeyResult?.status).to.eq(1, "failed to add key during setup"); + const { generatedR1Key, passkeyValidator } = await addNewWebAuthnKey(wallet, keyDomain); const sampleClientBuffer = Buffer.from(sampleClientString); const partiallyHashedData = concat([authData, await toHash(sampleClientBuffer)]); @@ -434,6 +436,59 @@ async function validateSignatureTest( return await passkeyValidator.validateSignature(transactionHash, fatSignature); } +async function addPasskey(index: number, passKeyModuleAddress: string, proxyAccountAddress: string, r1Key: CryptoKeyPair, sampleDomain: string, wallet: Wallet, provider: Provider) { + const passkeyValidator = WebAuthValidator__factory.connect(passKeyModuleAddress, new SmartAccount({ address: proxyAccountAddress, secret: wallet.privateKey }, provider)); + assert(r1Key != null, "no key was generated"); + const [generatedX, generatedY] = await getRawPublicKeyFromCrpyto(r1Key); + const initPasskeyData = encodeKeyFromBytes([generatedX, generatedY], sampleDomain); + const addedSecondPasskey = await passkeyValidator.addValidationKey(initPasskeyData); + const secondRecipt = await addedSecondPasskey.wait(); + expect(secondRecipt?.status).to.eq(1, "second key added"); + await verifyKeyStorage(passkeyValidator, sampleDomain, index, [toHex(generatedX), toHex(generatedY)], proxyAccountAddress, "second key"); +} + +async function createSmartAccount( + sampleDomain: string, + r1Key: CryptoKeyPair, + authData: Uint8Array, + passKeyModuleAddress: Hex, + proxyAccountAddress: string, + wallet: Wallet, + provider: Provider, +) { + return new SmartAccount({ + payloadSigner: async (hash: Hex) => { + const sampleClientObject = { + type: "webauthn.get", + challenge: fromBuffer(hexToBytes(hash)), + origin: sampleDomain, + crossOrigin: false, + }; + const sampleClientString = JSON.stringify(sampleClientObject); + const sampleClientBuffer = Buffer.from(sampleClientString); + const partiallyHashedData = concat([authData, await toHash(sampleClientBuffer)]); + const generatedSignature = await signStringWithR1Key(r1Key.privateKey, partiallyHashedData); + assert(generatedSignature != null, "no signature generated"); + const fatSignature = encodeAbiParameters([ + { name: "authData", type: "bytes" }, + { name: "clientDataJson", type: "string" }, + { name: "rs", type: "bytes32[2]" }, + ], [ + toHex(authData), + sampleClientString, + [toHex(normalizeR(generatedSignature.r)), toHex(normalizeS(generatedSignature.s))], + ]); + + const moduleSignature = encodeAbiParameters( + [{ name: "signature", type: "bytes" }, { name: "moduleAddress", type: "address" }, { name: "validatorData", type: "bytes" }], + [fatSignature, passKeyModuleAddress, "0x"]); + return moduleSignature; + }, + address: proxyAccountAddress, + secret: wallet.privateKey, // generatedR1Key.privateKey, + }, provider); +} + describe("Passkey validation", function () { const wallet = getWallet(LOCAL_RICH_WALLETS[0].privateKey); const ethersResponse = new RecordedResponse("test/signed-challenge.json"); @@ -447,6 +502,19 @@ describe("Passkey validation", function () { describe("account integration", () => { const fixtures = new ContractFixtures(); const provider = getProvider(); + async function createTx(proxyAccountAddress: string) { + return { + to: wallet.address, + type: 113, + from: proxyAccountAddress, + data: "0x", + value: 0, + chainId: (await provider.getNetwork()).chainId, + nonce: await provider.getTransactionCount(proxyAccountAddress), + gasPrice: await provider.getGasPrice(), + gasLimit: 100_000_000n, + }; + } async function deployAccount() { const factoryContract = await fixtures.getAaFactory(); @@ -504,55 +572,33 @@ describe("Passkey validation", function () { const authData = toBuffer(ethersResponse.authenticatorData); const { sampleDomain, proxyAccountAddress, generatedR1Key, passKeyModuleAddress } = await deployAccount(); - const sessionAccount = new SmartAccount({ - payloadSigner: async (hash: Hex) => { - const sampleClientObject = { - type: "webauthn.get", - challenge: fromBuffer(hexToBytes(hash)), - origin: sampleDomain, - crossOrigin: false, - }; - const sampleClientString = JSON.stringify(sampleClientObject); - const sampleClientBuffer = Buffer.from(sampleClientString); - const partiallyHashedData = concat([authData, await toHash(sampleClientBuffer)]); - const generatedSignature = await signStringWithR1Key(generatedR1Key.privateKey, partiallyHashedData); - assert(generatedSignature != null, "no signature generated"); - const fatSignature = encodeAbiParameters([ - { name: "authData", type: "bytes" }, - { name: "clientDataJson", type: "string" }, - { name: "rs", type: "bytes32[2]" }, - ], [ - toHex(authData), - sampleClientString, - [toHex(normalizeR(generatedSignature.r)), toHex(normalizeS(generatedSignature.s))], - ]); - - const moduleSignature = encodeAbiParameters( - [{ name: "signature", type: "bytes" }, { name: "moduleAddress", type: "address" }, { name: "validatorData", type: "bytes" }], - [fatSignature, passKeyModuleAddress, "0x"]); - return moduleSignature; - }, - address: proxyAccountAddress, - secret: wallet.privateKey, // generatedR1Key.privateKey, - }, provider); + const sessionAccount = await createSmartAccount(sampleDomain, generatedR1Key, authData, passKeyModuleAddress, proxyAccountAddress, wallet, provider); + const aaTransaction = await createTx(proxyAccountAddress); - const aaTransaction = { - to: wallet.address, - type: 113, - from: proxyAccountAddress, - data: "0x", - value: 0, - chainId: (await provider.getNetwork()).chainId, - nonce: await provider.getTransactionCount(proxyAccountAddress), - gasPrice: await provider.getGasPrice(), - gasLimit: 100_000_000n, - }; + const signedTransaction = await sessionAccount.signTransaction(aaTransaction); + const transactionResponse = await provider.broadcastTransaction(signedTransaction); + const transactionReceipt = await transactionResponse.wait(); + expect(transactionReceipt.status).to.eq(1, "transaction should be successful"); + logInfo(`passkey transaction gas used: ${transactionReceipt.gasUsed.toString()}`); + }); + + it("should sign transaction with second passkey", async () => { + const authData = toBuffer(ethersResponse.authenticatorData); + const { sampleDomain, proxyAccountAddress, passKeyModuleAddress } = await deployAccount(); + + // can add more passkeys here to measure per-gas increase + const lastPasskey = await generateES256R1Key(); + await addPasskey(1, passKeyModuleAddress, proxyAccountAddress, lastPasskey, sampleDomain, wallet, provider); + + const sessionAccount = await createSmartAccount(sampleDomain, lastPasskey, authData, passKeyModuleAddress, proxyAccountAddress, wallet, provider); + + const aaTransaction = await createTx(proxyAccountAddress); const signedTransaction = await sessionAccount.signTransaction(aaTransaction); const transactionResponse = await provider.broadcastTransaction(signedTransaction); const transactionReceipt = await transactionResponse.wait(); expect(transactionReceipt.status).to.eq(1, "transaction should be successful"); - logInfo(`passkey transaction gas used: ${transactionReceipt?.gasUsed.toString()}`); + logInfo(`passkey transaction gas used: ${transactionReceipt.gasUsed.toString()}`); }); }); @@ -593,8 +639,8 @@ describe("Passkey validation", function () { const keyReceipt = await secondCreatedKey.wait(); assert(keyReceipt?.status == 1, "second key was saved"); - await verifyKeyStorage(passkeyValidator, firstDomain, 0, publicKeys, wallet, "first domain"); - await verifyKeyStorage(passkeyValidator, secondDomain, 0, publicKeys, wallet, "second domain"); + await verifyKeyStorage(passkeyValidator, firstDomain, 0, publicKeys, wallet.address, "first domain"); + await verifyKeyStorage(passkeyValidator, secondDomain, 0, publicKeys, wallet.address, "second domain"); }); it("should add second key to the same domain", async () => { @@ -608,7 +654,7 @@ describe("Passkey validation", function () { const receipt = await generatedKeyAdded.wait(); assert(receipt?.status == 1, "generated key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet, "first key"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet.address, "first key"); const nextR1Key = await generateES256R1Key(); assert(nextR1Key != null, "no second key was generated"); @@ -618,8 +664,8 @@ describe("Passkey validation", function () { const newReceipt = await nextKeyAdded.wait(); assert(newReceipt?.status == 1, "new generated key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet, "first key"); - await verifyKeyStorage(passkeyValidator, keyDomain, 1, [toHex(newX), toHex(newY)], wallet, "updated key"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet.address, "first key"); + await verifyKeyStorage(passkeyValidator, keyDomain, 1, [toHex(newX), toHex(newY)], wallet.address, "updated key"); }); it("should allow clearing existing key", async () => { @@ -632,14 +678,14 @@ describe("Passkey validation", function () { const generatedKeyAdded = await passkeyValidator.addValidationKey(generatedKey); const receipt = await generatedKeyAdded.wait(); assert(receipt?.status == 1, "generated key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet, "added"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(generatedX), toHex(generatedY)], wallet.address, "added"); const zeroKey = new Uint8Array(32).fill(0); const emptyKeyAdded = await passkeyValidator.removeValidationKey(keyDomain, 0); const emptyReceipt = await emptyKeyAdded.wait(); assert(emptyReceipt?.status == 1, "empty key added"); - await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(zeroKey), toHex(zeroKey)], wallet, "key removed"); + await verifyKeyStorage(passkeyValidator, keyDomain, 0, [toHex(zeroKey), toHex(zeroKey)], wallet.address, "key removed"); }); }); @@ -654,7 +700,6 @@ describe("Passkey validation", function () { clientDataJSON: ethersResponse.clientData, signature: ethersResponse.b64SignedChallenge, }, - { passkey: publicKeys[0] }, ); const initData = encodeKeyFromHex(publicKeys, "http://localhost:5173"); @@ -796,6 +841,30 @@ describe("Passkey validation", function () { assert(isValidSignature, "valid signature"); }); + it("should verify signature from the 2nd passkey", async () => { + const keyDomain = randomBytes(32).toString("hex"); + const sampleClientObject = { + type: "webauthn.get", + challenge: "iBBiiOGt1aSBy1WAuRGxqU7YzRM5oWpMA9g8MKydjPI", + origin: keyDomain, + crossOrigin: false, + }; + const sampleClientString = JSON.stringify(sampleClientObject); + const authData = toBuffer(ethersResponse.authenticatorData); + const transactionHash = Buffer.from(sampleClientObject.challenge, "base64url"); + await addNewWebAuthnKey(wallet, keyDomain); + const isValidSignature = await validateSignatureTest( + wallet, + keyDomain, + authData, + normalizeS, + normalizeR, + sampleClientString, + transactionHash, + ); + assert(isValidSignature, "valid signature"); + }); + it("should verify a signature without cross-origin set", async () => { const keyDomain = randomBytes(32).toString("hex"); const sampleClientObject = { diff --git a/test/utils.ts b/test/utils.ts index 479d2b85..499934c7 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -6,20 +6,19 @@ import { ethers, parseEther, randomBytes } from "ethers"; import { readFileSync } from "fs"; import { promises } from "fs"; import * as hre from "hardhat"; +import { Address, isHex, toHex } from "viem"; import { ContractFactory, Provider, utils, Wallet } from "zksync-ethers"; import { base64UrlToUint8Array, getPublicKeyBytesFromPasskeySignature, unwrapEC2Signature } from "zksync-sso/utils"; -import { Address, isHex, toHex } from "viem"; import type { AAFactory, + AccountProxy, ERC20, ExampleAuthServerPaymaster, SessionKeyValidator, SsoAccount, - WebAuthValidator, SsoBeacon, - AccountProxy -} from "../typechain-types"; + WebAuthValidator } from "../typechain-types"; import { AAFactory__factory, AccountProxy__factory, @@ -27,10 +26,9 @@ import { ExampleAuthServerPaymaster__factory, SessionKeyValidator__factory, SsoAccount__factory, - WebAuthValidator__factory, SsoBeacon__factory, - TestPaymaster__factory -} from "../typechain-types"; + TestPaymaster__factory, + WebAuthValidator__factory } from "../typechain-types"; export const ethersStaticSalt = new Uint8Array([ 205, 241, 161, 186, 101, 105, 79, @@ -95,7 +93,7 @@ export class ContractFixtures { async getPasskeyModuleAddress(): Promise
{ const webAuthnVerifierContract = await this.getWebAuthnVerifierContract(); - const contractAddress = await webAuthnVerifierContract.getAddress() + const contractAddress = await webAuthnVerifierContract.getAddress(); return isHex(contractAddress) ? contractAddress : toHex(contractAddress); } @@ -309,13 +307,13 @@ const masterWallet = ethers.Wallet.fromPhrase("stuff slice staff easily soup par export const LOCAL_RICH_WALLETS = [ hre.network.name == "dockerizedNode" ? { - address: masterWallet.address, - privateKey: masterWallet.privateKey, - } + address: masterWallet.address, + privateKey: masterWallet.privateKey, + } : { - address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - privateKey: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - }, + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + privateKey: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + }, { address: "0x36615Cf349d7F6344891B1e7CA7C72883F5dc049", privateKey: "0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110",