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

Use assembly in WebAuthnVerifier for Base64 encoding #318

Merged
merged 12 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions modules/passkey/contracts/libraries/Base64Url.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

/**
* @title Base64Url Encoding Library
* @dev Provides a function to encode `bytes32` data into a Base64 URL string representation without '=' padding.
* @notice This library is adapted from solady's Base64 library and optimized for bytes32 encoding, which is useful for
* WebAuthn-based cryptographic operations.
* @author Modified from Solady (https://github.com/Vectorized/solady/blob/e4a14a5b365b353352f7c38e699a2bc9363d6576/src/utils/Base64.sol)
*/
library Base64Url {
/**
* @dev Encodes `bytes32` data into a Base64 URL string without '=' padding.
* @param data The `bytes32` input data to be encoded.
* @return result The encoded string in Base64 URL format.
*/
function encode(bytes32 data) internal pure returns (string memory result) {
// solhint-disable-next-line no-inline-assembly
assembly ("memory-safe") {
// The length of the encoded string (43 characters for bytes32 input).
// (32 bytes * 8 bits) / 6 bits base64 groups = 43 characters rounded.
let encodedLength := 43

// Set `result` to point to the start of the free memory.
// This is where the encoded string will be stored.
result := mload(0x40)

// Store the Base64 URL character table into the scratch space.
// The table is split into two parts and stored at memory locations 0x1f and 0x3f.
// Offsetted by -1 byte so that the `mload` will load the correct character.
// We will rewrite the free memory pointer at `0x40` later with the allocated size.
mstore(0x1f, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef")
mstore(0x3f, "ghijklmnopqrstuvwxyz0123456789-_")

// Initialize pointers for writing the encoded data.
// `ptr` points to the start of the encoded string (skipping the first slot that stores the length).
// `end` points to the end of the encoded string.
let ptr := add(result, 0x20)
let end := add(ptr, encodedLength)

// To minimize stack operations, we unroll the loop.
// With full 6 iterations of the loop, we need to encode seven 6-bit groups and one 4-bit group.
// In total, it encodes 6 iterations * 7 groups * 6 bits = 252 bits.
// The remaining 4-bit group is encoded after the loop.
// `i` is initialized to 250, which is the number of bits by which we need to shift the data
// to get the first 6-bit group, and then we subtract 6 to get the next 6-bit group.
let i := 250
for {

} 1 {

} {
// Encode 6-bit groups into characters by looking them up in the character table.
// The encoded characters are written to the scratch space at memory locations 0 to 6.
// 0x3F is a mask to get the last 6 bits.
mstore8(0, mload(and(shr(i, data), 0x3F)))
mstore8(1, mload(and(shr(sub(i, 6), data), 0x3F)))
mstore8(2, mload(and(shr(sub(i, 12), data), 0x3F)))
mstore8(3, mload(and(shr(sub(i, 18), data), 0x3F)))
mstore8(4, mload(and(shr(sub(i, 24), data), 0x3F)))
mstore8(5, mload(and(shr(sub(i, 30), data), 0x3F)))
mstore8(6, mload(and(shr(sub(i, 36), data), 0x3F)))

// Write the encoded characters to the result string.
// The characters are loaded from memory locations 0 to 6 and stored at `ptr`.
mstore(ptr, mload(0x00))
// Advance the pointer by the number of bytes written (7 bytes in this case).
ptr := add(ptr, 0x7)
// Move the data pointer to the next 6-bit group.
// 42 = 6 bits * 7 (number of groups processed in each iteration).
i := sub(i, 42)

// We want to exit when all full 6 bits groups are encoded. After 6 iterations,
// I will be -2 and we need to make a signed(!) comparison with 0 to break the loop.
if iszero(sgt(i, 0)) {
break
}
}

// Encode the final 4-bit group.
// 0x0F is a mask to get the last 4 bits.
// The encoded character is stored at memory location 74 (result + 74):
// <32byte string length><43byte encoded string>. 74 is the penultimate byte.
mstore8(add(result, 74), mload(shl(2, and(data, 0x0F))))

// Update the free memory pointer to point to the end of the encoded string.
// Store the length of the encoded string at the beginning of `result`.
mstore(0x40, end)
mstore(result, encodedLength)
}
}
}
5 changes: 3 additions & 2 deletions modules/passkey/contracts/libraries/WebAuthn.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.0;

import {Base64Url} from "../vendor/FCL/utils/Base64Url.sol";
import {Base64Url} from "../libraries/Base64Url.sol";
import {IP256Verifier, P256} from "./P256.sol";

/**
Expand Down Expand Up @@ -102,7 +102,8 @@ library WebAuthn {
bytes calldata authenticatorData,
string calldata clientDataFields
) internal pure returns (bytes32 message) {
string memory encodedChallenge = Base64Url.encode(abi.encodePacked(challenge));
string memory encodedChallenge = Base64Url.encode(challenge);

/* solhint-disable quotes */
bytes memory clientDataJson = abi.encodePacked(
'{"type":"webauthn.get","challenge":"',
Expand Down
10 changes: 10 additions & 0 deletions modules/passkey/contracts/test/TestBase64UrlLib.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.0;

import {Base64Url} from "../libraries/Base64Url.sol";

contract TestBase64UrlLib {
function encode(bytes32 data) public pure returns (string memory) {
return Base64Url.encode(data);
}
}
76 changes: 0 additions & 76 deletions modules/passkey/contracts/vendor/FCL/utils/Base64Url.sol

This file was deleted.

1 change: 1 addition & 0 deletions modules/passkey/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import dotenv from 'dotenv'
import type { HardhatUserConfig } from 'hardhat/config'
import 'hardhat-deploy'
import { HttpNetworkUserConfig } from 'hardhat/types'
import './src/tasks/codesize'

dotenv.config()
const { CUSTOM_NODE_URL, MNEMONIC, ETHERSCAN_API_KEY, PK } = process.env
Expand Down
1 change: 1 addition & 0 deletions modules/passkey/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"build:sol": "rimraf build typechain-types && hardhat compile",
"build:ts": "rimraf dist && tsc",
"coverage": "hardhat coverage",
"codesize": "hardhat codesize",
"fmt": "prettier --write .",
"fmt:check": "prettier --check .",
"lint": "npm run lint:sol && npm run lint:ts",
Expand Down
37 changes: 37 additions & 0 deletions modules/passkey/src/tasks/codesize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { task, types } from 'hardhat/config'
import { loadSolc } from '../utils/solc'

task('codesize', 'Displays the codesize of the contracts')
.addParam('skipcompile', 'should not compile before printing size', false, types.boolean, true)
.addParam('contractname', 'name of the contract', undefined, types.string, true)
.setAction(async (taskArgs, hre) => {
if (!taskArgs.skipcompile) {
await hre.run('compile')
}
const contracts = await hre.artifacts.getAllFullyQualifiedNames()
for (const contract of contracts) {
const artifact = await hre.artifacts.readArtifact(contract)
if (taskArgs.contractname && taskArgs.contractname !== artifact.contractName) continue
console.log(artifact.contractName, hre.ethers.dataLength(artifact.deployedBytecode), 'bytes (limit is 24576)')
}
})

task('yulcode', 'Outputs yul code for contracts')
.addParam('contractname', 'name of the contract', undefined, types.string, true)
.setAction(async (taskArgs, hre) => {
const contracts = await hre.artifacts.getAllFullyQualifiedNames()
for (const contract of contracts) {
if (taskArgs.contractname && !contract.endsWith(taskArgs.contractname)) continue
const buildInfo = await hre.artifacts.getBuildInfo(contract)
if (!buildInfo) return
console.log({ buildInfo })
buildInfo.input.settings.outputSelection['*']['*'].push('ir', 'evm.assembly')
const solcjs = await loadSolc(buildInfo.solcLongVersion)
const compiled = solcjs.compile(JSON.stringify(buildInfo.input))
const output = JSON.parse(compiled)
console.log(output.contracts[contract.split(':')[0]])
console.log(output.errors)
}
})

export {}
9 changes: 9 additions & 0 deletions modules/passkey/src/types/solc.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare module 'solc' {
export type Compiler = {
compile: (input: string) => string
}

export function compile(input: string): string

export function loadRemoteVersion(version: string, callback: (err: Error, solc: Compiler) => void): void
}
15 changes: 15 additions & 0 deletions modules/passkey/src/utils/solc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import solc from 'solc'
import type { Compiler } from 'solc'

const solcCache: Record<string, Compiler> = {}

export const loadSolc = async (version: string): Promise<Compiler> => {
return await new Promise((resolve, reject) => {
if (solcCache[version] !== undefined) resolve(solcCache[version])
else
solc.loadRemoteVersion(`v${version}`, (error, solcjs) => {
solcCache[version] = solcjs
return error ? reject(error) : resolve(solcjs)
})
})
}
30 changes: 30 additions & 0 deletions modules/passkey/test/libraries/Base64Url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect } from 'chai'
import { deployments, ethers } from 'hardhat'

const base64 = {
encodeFromHex: (h: string) => {
const normalized = h.startsWith('0x') ? h.slice(2) : h

return Buffer.from(normalized, 'hex').toString('base64url')
},
}

describe('WebAuthn Library', () => {
const setupTests = deployments.createFixture(async () => {
const Base64UrlLibFactory = await ethers.getContractFactory('TestBase64UrlLib')
const base64UrlLib = await Base64UrlLibFactory.deploy()

return { base64UrlLib }
})

it('Encode: Should correctly base64 encode a bytes32 hash', async () => {
const { base64UrlLib } = await setupTests()

for (let i = 0; i < 50; i++) {
const input = ethers.keccak256(ethers.randomBytes(32))
const base64Encoded = base64.encodeFromHex(input)

expect(await base64UrlLib.encode(input)).to.be.equal(base64Encoded)
}
})
})
3 changes: 2 additions & 1 deletion modules/passkey/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
},
"include": ["src/**/*.ts", "hardhat.config.ts", "test"]
}
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading