From c02615b89bbfad778cde1fbc2cc455e76b31863c Mon Sep 17 00:00:00 2001 From: Vectorized Date: Mon, 28 Oct 2024 01:29:48 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20WebAuthn=20(#1132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prep/gen-globalized-libs.js | 2 +- src/utils/WebAuthn.sol | 282 +++++++++++++++++++++++++++++++++++ src/utils/g/WebAuthn.sol | 286 ++++++++++++++++++++++++++++++++++++ test/P256.t.sol | 56 ++++--- test/WebAuthn.t.sol | 224 ++++++++++++++++++++++++++++ 5 files changed, 831 insertions(+), 19 deletions(-) create mode 100644 src/utils/WebAuthn.sol create mode 100644 src/utils/g/WebAuthn.sol create mode 100644 test/WebAuthn.t.sol diff --git a/prep/gen-globalized-libs.js b/prep/gen-globalized-libs.js index f5a29290d..cddbf9e58 100644 --- a/prep/gen-globalized-libs.js +++ b/prep/gen-globalized-libs.js @@ -45,7 +45,7 @@ async function main() { ].join('\n\n') ) .replace(/(https\:\/\/\S+?\/solady\/\S+?\/)([A-Za-z0-9]+\.sol)/, '$1g/$2') - .replace(/(import\s[\s\S]*?["'])\.\/([\s\S]+["'])/g, '$1../$2') + .replace(/(import\s[\s\S]*?["'])\.\/([\s\S]+?["'])/g, '$1../$2') .replace(/(library\s+([A-Za-z0-9]+)\s+\{\n)\n*/, '$1') ); }); diff --git a/src/utils/WebAuthn.sol b/src/utils/WebAuthn.sol new file mode 100644 index 000000000..70f4a1e0a --- /dev/null +++ b/src/utils/WebAuthn.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {Base64} from "./Base64.sol"; +import {P256} from "./P256.sol"; + +/// @notice WebAuthn helper. +/// @author Solady (https://github.com/vectorized/solady/blob/main/src/utils/WebAuthn.sol) +/// @author Modified from Daimo WebAuthn (https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol) +/// @author Modified from Coinbase WebAuthn (https://github.com/base-org/webauthn-sol/blob/main/src/WebAuthn.sol) +library WebAuthn { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* STRUCTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Helps make encoding and decoding easier, alleviates stack-too-deep. + struct WebAuthnAuth { + // The WebAuthn authenticator data. + // See: https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata. + bytes authenticatorData; + // The WebAuthn client data JSON. + // See: https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson. + string clientDataJSON; + // The index at which "challenge":"..." occurs in `clientDataJSON`. + uint256 challengeIndex; + // The index at which "type":"..." occurs in `clientDataJSON`. + uint256 typeIndex; + // The r value of secp256r1 signature + bytes32 r; + // The s value of secp256r1 signature + bytes32 s; + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONSTANTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Bit 0 of the authenticator data struct, corresponding to the "User Present" bit. + /// See: https://www.w3.org/TR/webauthn-2/#flags. + uint256 private constant _AUTH_DATA_FLAGS_UP = 0x01; + + /// @dev Bit 2 of the authenticator data struct, corresponding to the "User Verified" bit. + /// See: https://www.w3.org/TR/webauthn-2/#flags. + uint256 private constant _AUTH_DATA_FLAGS_UV = 0x04; + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* WEBAUTHN VERIFICATION OPERATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Verifies a Webauthn Authentication Assertion. + /// See: https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion. + /// + /// We do not verify all the steps as described in the specification, only ones + /// relevant to our context. Please carefully read through this list before usage. + /// + /// Specifically, we do verify the following: + /// - Verify that `authenticatorData` (which comes from the authenticator, + /// such as iCloud Keychain) indicates a well-formed assertion with the + /// "User Present" bit set. If `requireUserVerification` is set, checks that the + /// authenticator enforced user verification. User verification should be required + /// if, and only if, `options.userVerification` is set to required in the request. + /// - Verifies that the client JSON is of type "webauthn.get", + /// i.e. the client was responding to a request to assert authentication. + /// - Verifies that the client JSON contains the requested challenge. + /// - Verifies that (r, s) constitute a valid signature over both the + /// `authData` and client JSON, for public key (x, y). + /// + /// We make some assumptions about the particular use case of this verifier, + /// so we do NOT verify the following: + /// - Does NOT verify that the origin in the `clientDataJSON` matches the + /// Relying Party's origin: it is considered the authenticator's responsibility to + /// ensure that the user is interacting with the correct RP. This is enforced by + /// most high quality authenticators properly, particularly the iCloud Keychain + /// and Google Password Manager were tested. + /// - Does NOT verify That `topOrigin` in `clientDataJSON` is well-formed: + /// We assume it would never be present, i.e. the credentials are never used in a + /// cross-origin/iframe context. The website/app set up should disallow cross-origin + /// usage of the credentials. This is the default behavior for created credentials + /// in common settings. + /// - Does NOT verify that the `rpIdHash` in `authenticatorData` is the SHA-256 hash + /// of the RP ID expected by the Relying Party: + /// this means that we rely on the authenticator to properly enforce + /// credentials to be used only by the correct RP. + /// This is generally enforced with features like Apple App Site Association + /// and Google Asset Links. To protect from edge cases in which a previously-linked + /// RP ID is removed from the authorized RP IDs, we recommend that messages + /// signed by the authenticator include some expiry mechanism. + /// - Does NOT verify the credential backup state: this assumes the credential backup + /// state is NOT used as part of Relying Party business logic or policy. + /// - Does NOT verify the values of the client extension outputs: + /// this assumes that the Relying Party does not use client extension outputs. + /// - Does NOT verify the signature counter: signature counters are intended to enable + /// risk scoring for the Relying Party. This assumes risk scoring is not used as part + /// of Relying Party business logic or policy. + /// - Does NOT verify the attestation object: this assumes that + /// response.attestationObject is NOT present in the response, + /// i.e. the RP does not intend to verify an attestation. + function verify( + bytes memory challenge, + bool requireUserVerification, + WebAuthnAuth memory webAuthnAuth, + bytes32 x, + bytes32 y + ) internal view returns (bool result) { + bytes32 messageHash; + string memory encoded = Base64.encode(challenge, true, true); + /// @solidity memory-safe-assembly + assembly { + let clientDataJSON := mload(add(webAuthnAuth, 0x20)) + let n := mload(clientDataJSON) // `clientDataJSON`'s length. + let o := add(clientDataJSON, 0x20) // Start of `clientData`'s bytes. + { + let c := mload(add(webAuthnAuth, 0x40)) // Challenge index in `clientDataJSON`. + let t := mload(add(webAuthnAuth, 0x60)) // Type index in `clientDataJSON`. + let l := mload(encoded) // Cache `encoded`'s length. + let q := add(l, 0x0d) // Length of `encoded` prefixed with '"challenge":"'. + mstore(encoded, shr(152, '"challenge":"')) // Temp prefix with '"challenge":"'. + result := + and( + // 11. Verify JSON's type. Also checks for possible addition overflows. + and( + eq(shr(88, mload(add(o, t))), shr(88, '"type":"webauthn.get"')), + lt(shr(128, or(t, c)), lt(add(0x14, t), n)) + ), + // 12. Verify JSON's challenge. Includes a check for the closing '"'. + and( + eq(keccak256(add(o, c), q), keccak256(add(encoded, 0x13), q)), + and(eq(byte(0, mload(add(add(o, c), q))), 34), lt(add(q, c), n)) + ) + ) + mstore(encoded, l) // Restore `encoded`'s length, in case of string interning. + } + // Skip 13., 14., 15. + let authData := mload(webAuthnAuth) + let l := mload(authData) // Length of `authData`. + let r := + or( + // 16. Verify that the "User Present" flag is set. + _AUTH_DATA_FLAGS_UP, + // 17. Verify that the "User Verified" flag is set, if required. + mul(_AUTH_DATA_FLAGS_UV, iszero(iszero(requireUserVerification))) + ) + result := + and(and(result, gt(l, 0x20)), eq(and(byte(0, mload(add(authData, 0x40))), r), r)) + if result { + let p := add(authData, 0x20) // Start of `authData`'s bytes. + let e := add(p, l) // Location of the word after `authData`. + let w := mload(e) // Cache the word after `authData`. + // 19. Compute `sha256(clientDataJSON)`. + pop(staticcall(gas(), 2, o, n, e, 0x20)) + // 20. Compute `sha256(authData ‖ sha256(clientDataJSON))`. + pop(staticcall(gas(), 2, p, add(l, 0x20), 0x00, returndatasize())) + // `returndatasize()` is `0x20` on `sha256` success, and `0x00` otherwise. + if iszero(returndatasize()) { invalid() } + mstore(e, w) // Restore the word after `authData`, in case of reuse. + messageHash := mload(0x00) + } + } + // `P256.verifySignature` returns false if `s > N/2` due to the malleability check. + return result && P256.verifySignature(messageHash, webAuthnAuth.r, webAuthnAuth.s, x, y); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* ENCODING / DECODING HELPERS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev For reference. Intended for off-chain usage. + function encodeAuth(WebAuthnAuth memory webAuthnAuth) internal pure returns (bytes memory) { + return abi.encode(webAuthnAuth); + } + + /// @dev Performs a best-effort attempt to `abi.decode(webAuthnAuth)`. Won't revert. + /// If not all fields can be successfully extracted, the `clientDataJSON` + /// will be left as the empty string, which will cause `verify` to return false. + function tryDecodeAuth(bytes memory encodedAuth) + internal + pure + returns (WebAuthnAuth memory decoded) + { + /// @solidity memory-safe-assembly + assembly { + let n := mload(encodedAuth) + if iszero(lt(n, 0xc0)) { + let o := add(encodedAuth, 0x20) // Start of `encodedAuth`'s bytes. + let e := add(o, n) // End of `encodedAuth` in memory. + let p := add(mload(o), o) + if iszero(gt(add(p, 0xc0), e)) { + let q := add(mload(p), p) + if iszero(gt(q, e)) { + if iszero(gt(add(add(q, 0x20), mload(q)), e)) { + mstore(decoded, q) // `authenticatorData`. + q := add(mload(add(p, 0x20)), p) + if iszero(gt(q, e)) { + if iszero(gt(add(add(q, 0x20), mload(q)), e)) { + mstore(add(decoded, 0x20), q) // `clientDataJSON`. + } + } + } + } + mstore(add(decoded, 0x40), mload(add(p, 0x40))) // `challengeIndex`. + mstore(add(decoded, 0x60), mload(add(p, 0x60))) // `typeIndex`. + mstore(add(decoded, 0x80), mload(add(p, 0x80))) // `r`. + mstore(add(decoded, 0xa0), mload(add(p, 0xa0))) // `s`. + } + } + } + } + + /// @dev For reference. Intended for off-chain usage. + /// Returns the empty string if any length or index exceeds 16 bits. + function tryEncodeAuthCompact(WebAuthnAuth memory webAuthnAuth) + internal + pure + returns (bytes memory) + { + uint256 n = webAuthnAuth.authenticatorData.length; + n |= bytes(webAuthnAuth.clientDataJSON).length; + n |= webAuthnAuth.challengeIndex; + n |= webAuthnAuth.typeIndex; + if (n >= 0x10000) return ""; + // This is equivalent to a single flattened `abi.encodePacked`, + // but due to stack-too-deep, we have to do it in two steps. + return abi.encodePacked( + uint16(webAuthnAuth.authenticatorData.length), + webAuthnAuth.authenticatorData, + uint16(bytes(webAuthnAuth.clientDataJSON).length), + webAuthnAuth.clientDataJSON, + abi.encodePacked( + uint16(webAuthnAuth.challengeIndex), + uint16(webAuthnAuth.typeIndex), + webAuthnAuth.r, + webAuthnAuth.s + ) + ); + } + + /// @dev Approximately the same gas as `tryDecodeAuth`, but helps save on calldata. + /// If not all fields can be successfully extracted, the `clientDataJSON` + /// will be left as the empty string, which will cause `verify` to return false. + function tryDecodeAuthCompact(bytes memory encodedAuth) + internal + pure + returns (WebAuthnAuth memory decoded) + { + /// @solidity memory-safe-assembly + assembly { + function extractBytes(o_, l_) -> _m { + _m := mload(0x40) // Grab the free memory pointer. + let s_ := add(_m, 0x20) + for { let i_ := 0 } 1 {} { + mstore(add(s_, i_), mload(add(o_, i_))) + i_ := add(i_, 0x20) + if iszero(lt(i_, l_)) { break } + } + mstore(_m, l_) // Store the length. + mstore(add(l_, s_), 0) // Zeroize the slot after the string. + mstore(0x40, add(0x20, add(l_, s_))) // Allocate memory. + } + let n := mload(encodedAuth) + if iszero(lt(n, 0x48)) { + let o := add(encodedAuth, 0x20) // Start of `encodedAuth`'s bytes. + let e := add(o, n) // End of `encodedAuth` in memory. + let l := shr(240, mload(o)) // Length of `authenticatorData`. + o := add(o, 2) + if iszero(gt(add(o, l), e)) { + mstore(decoded, extractBytes(o, l)) // `authenticatorData`. + o := add(o, l) + l := shr(240, mload(o)) // Length of `clientDataJSON`. + o := add(o, 2) + if iszero(gt(add(o, add(0x44, l)), e)) { + mstore(add(decoded, 0x20), extractBytes(o, l)) // `clientDataJSON`. + o := add(o, l) + mstore(add(decoded, 0x40), shr(240, mload(o))) // `challengeIndex`. + mstore(add(decoded, 0x60), shr(240, mload(add(o, 0x02)))) // `typeIndex`. + mstore(add(decoded, 0x80), mload(add(o, 0x04))) // `r`. + mstore(add(decoded, 0xa0), mload(add(o, 0x24))) // `s`. + } + } + } + } + } +} diff --git a/src/utils/g/WebAuthn.sol b/src/utils/g/WebAuthn.sol new file mode 100644 index 000000000..bad965b52 --- /dev/null +++ b/src/utils/g/WebAuthn.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +// This file is auto-generated. + +/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* STRUCTS */ +/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + +/// @dev Helps make encoding and decoding easier, alleviates stack-too-deep. +struct WebAuthnAuth { + // The WebAuthn authenticator data. + // See: https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata. + bytes authenticatorData; + // The WebAuthn client data JSON. + // See: https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson. + string clientDataJSON; + // The index at which "challenge":"..." occurs in `clientDataJSON`. + uint256 challengeIndex; + // The index at which "type":"..." occurs in `clientDataJSON`. + uint256 typeIndex; + // The r value of secp256r1 signature + bytes32 r; + // The s value of secp256r1 signature + bytes32 s; +} + +using WebAuthn for WebAuthnAuth global; + +import {Base64} from "../Base64.sol"; +import {P256} from "../P256.sol"; + +/// @notice WebAuthn helper. +/// @author Solady (https://github.com/vectorized/solady/blob/main/src/utils/g/WebAuthn.sol) +/// @author Modified from Daimo WebAuthn (https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol) +/// @author Modified from Coinbase WebAuthn (https://github.com/base-org/webauthn-sol/blob/main/src/WebAuthn.sol) +library WebAuthn { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONSTANTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Bit 0 of the authenticator data struct, corresponding to the "User Present" bit. + /// See: https://www.w3.org/TR/webauthn-2/#flags. + uint256 private constant _AUTH_DATA_FLAGS_UP = 0x01; + + /// @dev Bit 2 of the authenticator data struct, corresponding to the "User Verified" bit. + /// See: https://www.w3.org/TR/webauthn-2/#flags. + uint256 private constant _AUTH_DATA_FLAGS_UV = 0x04; + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* WEBAUTHN VERIFICATION OPERATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Verifies a Webauthn Authentication Assertion. + /// See: https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion. + /// + /// We do not verify all the steps as described in the specification, only ones + /// relevant to our context. Please carefully read through this list before usage. + /// + /// Specifically, we do verify the following: + /// - Verify that `authenticatorData` (which comes from the authenticator, + /// such as iCloud Keychain) indicates a well-formed assertion with the + /// "User Present" bit set. If `requireUserVerification` is set, checks that the + /// authenticator enforced user verification. User verification should be required + /// if, and only if, `options.userVerification` is set to required in the request. + /// - Verifies that the client JSON is of type "webauthn.get", + /// i.e. the client was responding to a request to assert authentication. + /// - Verifies that the client JSON contains the requested challenge. + /// - Verifies that (r, s) constitute a valid signature over both the + /// `authData` and client JSON, for public key (x, y). + /// + /// We make some assumptions about the particular use case of this verifier, + /// so we do NOT verify the following: + /// - Does NOT verify that the origin in the `clientDataJSON` matches the + /// Relying Party's origin: it is considered the authenticator's responsibility to + /// ensure that the user is interacting with the correct RP. This is enforced by + /// most high quality authenticators properly, particularly the iCloud Keychain + /// and Google Password Manager were tested. + /// - Does NOT verify That `topOrigin` in `clientDataJSON` is well-formed: + /// We assume it would never be present, i.e. the credentials are never used in a + /// cross-origin/iframe context. The website/app set up should disallow cross-origin + /// usage of the credentials. This is the default behavior for created credentials + /// in common settings. + /// - Does NOT verify that the `rpIdHash` in `authenticatorData` is the SHA-256 hash + /// of the RP ID expected by the Relying Party: + /// this means that we rely on the authenticator to properly enforce + /// credentials to be used only by the correct RP. + /// This is generally enforced with features like Apple App Site Association + /// and Google Asset Links. To protect from edge cases in which a previously-linked + /// RP ID is removed from the authorized RP IDs, we recommend that messages + /// signed by the authenticator include some expiry mechanism. + /// - Does NOT verify the credential backup state: this assumes the credential backup + /// state is NOT used as part of Relying Party business logic or policy. + /// - Does NOT verify the values of the client extension outputs: + /// this assumes that the Relying Party does not use client extension outputs. + /// - Does NOT verify the signature counter: signature counters are intended to enable + /// risk scoring for the Relying Party. This assumes risk scoring is not used as part + /// of Relying Party business logic or policy. + /// - Does NOT verify the attestation object: this assumes that + /// response.attestationObject is NOT present in the response, + /// i.e. the RP does not intend to verify an attestation. + function verify( + bytes memory challenge, + bool requireUserVerification, + WebAuthnAuth memory webAuthnAuth, + bytes32 x, + bytes32 y + ) internal view returns (bool result) { + bytes32 messageHash; + string memory encoded = Base64.encode(challenge, true, true); + /// @solidity memory-safe-assembly + assembly { + let clientDataJSON := mload(add(webAuthnAuth, 0x20)) + let n := mload(clientDataJSON) // `clientDataJSON`'s length. + let o := add(clientDataJSON, 0x20) // Start of `clientData`'s bytes. + { + let c := mload(add(webAuthnAuth, 0x40)) // Challenge index in `clientDataJSON`. + let t := mload(add(webAuthnAuth, 0x60)) // Type index in `clientDataJSON`. + let l := mload(encoded) // Cache `encoded`'s length. + let q := add(l, 0x0d) // Length of `encoded` prefixed with '"challenge":"'. + mstore(encoded, shr(152, '"challenge":"')) // Temp prefix with '"challenge":"'. + result := + and( + // 11. Verify JSON's type. Also checks for possible addition overflows. + and( + eq(shr(88, mload(add(o, t))), shr(88, '"type":"webauthn.get"')), + lt(shr(128, or(t, c)), lt(add(0x14, t), n)) + ), + // 12. Verify JSON's challenge. Includes a check for the closing '"'. + and( + eq(keccak256(add(o, c), q), keccak256(add(encoded, 0x13), q)), + and(eq(byte(0, mload(add(add(o, c), q))), 34), lt(add(q, c), n)) + ) + ) + mstore(encoded, l) // Restore `encoded`'s length, in case of string interning. + } + // Skip 13., 14., 15. + let authData := mload(webAuthnAuth) + let l := mload(authData) // Length of `authData`. + let r := + or( + // 16. Verify that the "User Present" flag is set. + _AUTH_DATA_FLAGS_UP, + // 17. Verify that the "User Verified" flag is set, if required. + mul(_AUTH_DATA_FLAGS_UV, iszero(iszero(requireUserVerification))) + ) + result := + and(and(result, gt(l, 0x20)), eq(and(byte(0, mload(add(authData, 0x40))), r), r)) + if result { + let p := add(authData, 0x20) // Start of `authData`'s bytes. + let e := add(p, l) // Location of the word after `authData`. + let w := mload(e) // Cache the word after `authData`. + // 19. Compute `sha256(clientDataJSON)`. + pop(staticcall(gas(), 2, o, n, e, 0x20)) + // 20. Compute `sha256(authData ‖ sha256(clientDataJSON))`. + pop(staticcall(gas(), 2, p, add(l, 0x20), 0x00, returndatasize())) + // `returndatasize()` is `0x20` on `sha256` success, and `0x00` otherwise. + if iszero(returndatasize()) { invalid() } + mstore(e, w) // Restore the word after `authData`, in case of reuse. + messageHash := mload(0x00) + } + } + // `P256.verifySignature` returns false if `s > N/2` due to the malleability check. + return result && P256.verifySignature(messageHash, webAuthnAuth.r, webAuthnAuth.s, x, y); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* ENCODING / DECODING HELPERS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev For reference. Intended for off-chain usage. + function encodeAuth(WebAuthnAuth memory webAuthnAuth) internal pure returns (bytes memory) { + return abi.encode(webAuthnAuth); + } + + /// @dev Performs a best-effort attempt to `abi.decode(webAuthnAuth)`. Won't revert. + /// If not all fields can be successfully extracted, the `clientDataJSON` + /// will be left as the empty string, which will cause `verify` to return false. + function tryDecodeAuth(bytes memory encodedAuth) + internal + pure + returns (WebAuthnAuth memory decoded) + { + /// @solidity memory-safe-assembly + assembly { + let n := mload(encodedAuth) + if iszero(lt(n, 0xc0)) { + let o := add(encodedAuth, 0x20) // Start of `encodedAuth`'s bytes. + let e := add(o, n) // End of `encodedAuth` in memory. + let p := add(mload(o), o) + if iszero(gt(add(p, 0xc0), e)) { + let q := add(mload(p), p) + if iszero(gt(q, e)) { + if iszero(gt(add(add(q, 0x20), mload(q)), e)) { + mstore(decoded, q) // `authenticatorData`. + q := add(mload(add(p, 0x20)), p) + if iszero(gt(q, e)) { + if iszero(gt(add(add(q, 0x20), mload(q)), e)) { + mstore(add(decoded, 0x20), q) // `clientDataJSON`. + } + } + } + } + mstore(add(decoded, 0x40), mload(add(p, 0x40))) // `challengeIndex`. + mstore(add(decoded, 0x60), mload(add(p, 0x60))) // `typeIndex`. + mstore(add(decoded, 0x80), mload(add(p, 0x80))) // `r`. + mstore(add(decoded, 0xa0), mload(add(p, 0xa0))) // `s`. + } + } + } + } + + /// @dev For reference. Intended for off-chain usage. + /// Returns the empty string if any length or index exceeds 16 bits. + function tryEncodeAuthCompact(WebAuthnAuth memory webAuthnAuth) + internal + pure + returns (bytes memory) + { + uint256 n = webAuthnAuth.authenticatorData.length; + n |= bytes(webAuthnAuth.clientDataJSON).length; + n |= webAuthnAuth.challengeIndex; + n |= webAuthnAuth.typeIndex; + if (n >= 0x10000) return ""; + // This is equivalent to a single flattened `abi.encodePacked`, + // but due to stack-too-deep, we have to do it in two steps. + return abi.encodePacked( + uint16(webAuthnAuth.authenticatorData.length), + webAuthnAuth.authenticatorData, + uint16(bytes(webAuthnAuth.clientDataJSON).length), + webAuthnAuth.clientDataJSON, + abi.encodePacked( + uint16(webAuthnAuth.challengeIndex), + uint16(webAuthnAuth.typeIndex), + webAuthnAuth.r, + webAuthnAuth.s + ) + ); + } + + /// @dev Approximately the same gas as `tryDecodeAuth`, but helps save on calldata. + /// If not all fields can be successfully extracted, the `clientDataJSON` + /// will be left as the empty string, which will cause `verify` to return false. + function tryDecodeAuthCompact(bytes memory encodedAuth) + internal + pure + returns (WebAuthnAuth memory decoded) + { + /// @solidity memory-safe-assembly + assembly { + function extractBytes(o_, l_) -> _m { + _m := mload(0x40) // Grab the free memory pointer. + let s_ := add(_m, 0x20) + for { let i_ := 0 } 1 {} { + mstore(add(s_, i_), mload(add(o_, i_))) + i_ := add(i_, 0x20) + if iszero(lt(i_, l_)) { break } + } + mstore(_m, l_) // Store the length. + mstore(add(l_, s_), 0) // Zeroize the slot after the string. + mstore(0x40, add(0x20, add(l_, s_))) // Allocate memory. + } + let n := mload(encodedAuth) + if iszero(lt(n, 0x48)) { + let o := add(encodedAuth, 0x20) // Start of `encodedAuth`'s bytes. + let e := add(o, n) // End of `encodedAuth` in memory. + let l := shr(240, mload(o)) // Length of `authenticatorData`. + o := add(o, 2) + if iszero(gt(add(o, l), e)) { + mstore(decoded, extractBytes(o, l)) // `authenticatorData`. + o := add(o, l) + l := shr(240, mload(o)) // Length of `clientDataJSON`. + o := add(o, 2) + if iszero(gt(add(o, add(0x44, l)), e)) { + mstore(add(decoded, 0x20), extractBytes(o, l)) // `clientDataJSON`. + o := add(o, l) + mstore(add(decoded, 0x40), shr(240, mload(o))) // `challengeIndex`. + mstore(add(decoded, 0x60), shr(240, mload(add(o, 0x02)))) // `typeIndex`. + mstore(add(decoded, 0x80), mload(add(o, 0x04))) // `r`. + mstore(add(decoded, 0xa0), mload(add(o, 0x24))) // `s`. + } + } + } + } + } +} diff --git a/test/P256.t.sol b/test/P256.t.sol index 1ed4718f4..d68348a14 100644 --- a/test/P256.t.sol +++ b/test/P256.t.sol @@ -5,10 +5,46 @@ import "./utils/SoladyTest.sol"; import {LibString} from "../src/utils/LibString.sol"; import {P256} from "../src/utils/P256.sol"; -contract P256Test is SoladyTest { - bytes private constant _VERIFIER_BYTECODE = +contract P256VerifierEtcher is SoladyTest { + bytes internal constant _VERIFIER_BYTECODE = hex"7fffffffff00000001000000000000000000000000ffffffffffffffffffffffff604052610199565b60008060008561003f5750859150869050876100cc565b886100515750829150839050846100cc565b60405180878809818b8c098281880983838c0984858f85098b09925084858c86098e099350848286038208905084818209858183098685880387089550868788848709600209880388838a038a8a8b09080899508687828709880388898d8b038b878a09088909089850505084858f8d098209955050505050505b96509650969350505050565b8160071b915081513d8301516040840151604051808384098183840982838388096004098384858485093d510985868a8b09600309089650838482600209850385898a090891508384858586096008098503858685880385088a0908965050828385870960020960079790971b9081523d810195909552505050506040015250565b8160071b91508260071b925061018460408401513d850151855160408601513d8701518751610028565b60079390931b9182523d820152604001525050565b6020357fffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc6325516040357f7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a88111156101eb5781035b60206108005260206108205260206108405280610860526002820361088052816108a0526040518060031860205260603560803560203d60c061080060055afa60203d1416837f5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b8585873d5189898a09080908848384091486861086151087891089151016609f36111616166102815760206080f35b60808281523d01819052600160c05250507f6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c2966102009081527f4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f53d9091015250506001610240526102f3600160026100d8565b6102ff600460086100d8565b61030d60026001600361015a565b61031b60046001600561015a565b61032960046002600661015a565b61033760046003600761015a565b61034560086001600961015a565b61035360086002600a61015a565b61036160086003600b61015a565b61036f60086004600c61015a565b61037d600c6001600d61015a565b61038b600c6002600e61015a565b610399600c6003600f61015a565b6000816000516000350982600051850960008060006040515b821561046657808485098184850982838386096004098384858485093d51098586888909600309088485836002098603868384090885868787880960080987038788848a03870885090886878a8c09600209878283099650878182099550878888850960040994508788898889093d5109898a868709600309089350878886600209890389868709089850878882840960020999505050508485868687096008098603868789890386088409089750505050505b61018085881b60f71c1661060087891b60f51c1617801561051e57604081015180610491575061051e565b846104a85781513d8301519650909450925061051e565b82858609838283098481870985868584098a09915085818703878588510908868182098781830988858a038a8b8e8a093d8c01510908955088898a8487096002098a038a838c038c8a8b090808995088898287098a038a8b8d8d038d878a09088909089b5050508687868b098209985050505050505b5082156105d357808485098184850982838386096004098384858485093d51098586888909600309088485836002098603868384090885868787880960080987038788848a03870885090886878a8c09600209878283099650878182099550878888850960040994508788898889093d5109898a868709600309089350878886600209890389868709089850878882840960020999505050508485868687096008098603868789890386088409089750505050505b61018085881b60f51c1661060087891b60f31c1617801561068b576040810151806105fe575061068b565b846106155781513d8301519650909450925061068b565b82858609838283098481870985868584098a09915085818703878588510908868182098781830988858a038a8b8e8a093d8c01510908955088898a8487096002098a038a838c038c8a8b090808995088898287098a038a8b8d8d038d878a09088909089b5050508687868b098209985050505050505b50600487019660fb19016103b257826106a75788153d5260203df35b82610860526002810361088052806108a0523d3d60c061080060055afa898983843d513d510986090614163d525050505050505050503d3df3fea26469706673582212206775789f4c3ac0130b20d36cc627c1cec82b85082a6b23157dc1409168ae969264736f6c634300081a0033"; + bytes internal constant _PASSTHROUGH_BYTECODE = hex"600160005260206000f3"; + + function _etchBytecode(address target, bytes memory bytecode, bool active) internal { + if (active) { + if (target.code.length == 0) vm.etch(target, bytecode); + } else { + if (target.code.length != 0) vm.etch(target, ""); + } + } + + function _etchPassthroughBytecode(address target, bool active) internal { + _etchBytecode(target, _PASSTHROUGH_BYTECODE, active); + } + + function _etchVerifierBytecode(address target, bool active) internal { + _etchBytecode(target, _VERIFIER_BYTECODE, active); + } + + function _etchRIPPrecompilePassthrough(bool active) internal { + _etchPassthroughBytecode(P256.RIP_PRECOMPILE, active); + } + + function _etchVerifierPassthrough(bool active) internal { + _etchPassthroughBytecode(P256.VERIFIER, active); + } + + function _etchRIPPrecompile(bool active) internal { + _etchVerifierBytecode(P256.RIP_PRECOMPILE, active); + } + + function _etchVerifier(bool active) internal { + _etchVerifierBytecode(P256.VERIFIER, active); + } +} + +contract P256Test is P256VerifierEtcher { // Public key x and y. uint256 private constant _X = 0x65a2fa44daad46eab0278703edb6c4dcf5e30b8a9aec09fdc71a56f52aa392e4; uint256 private constant _Y = 0x4a7a9e4604aa36898209997288e902ac544a555e4b5e0a9efef2b59233f3f437; @@ -31,22 +67,6 @@ contract P256Test is SoladyTest { _etchVerifier(true); } - function _etchVerifierBytecode(address target, bool active) internal { - if (active) { - if (target.code.length == 0) vm.etch(target, _VERIFIER_BYTECODE); - } else { - if (target.code.length != 0) vm.etch(target, ""); - } - } - - function _etchRIPPrecompile(bool active) internal { - _etchVerifierBytecode(P256.RIP_PRECOMPILE, active); - } - - function _etchVerifier(bool active) internal { - _etchVerifierBytecode(P256.VERIFIER, active); - } - function testP256VerifyMalleableRIPPrecompile() public { _testP256VerifyMalleable(); } diff --git a/test/WebAuthn.t.sol b/test/WebAuthn.t.sol new file mode 100644 index 000000000..3d428c9fb --- /dev/null +++ b/test/WebAuthn.t.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import "./utils/SoladyTest.sol"; +import {P256VerifierEtcher} from "./P256.t.sol"; +import {LibString} from "../src/utils/LibString.sol"; +import {Base64} from "../src/utils/Base64.sol"; +import {WebAuthn} from "../src/utils/WebAuthn.sol"; + +contract WebAuthnTest is P256VerifierEtcher { + function verify( + bytes memory challenge, + bool requireUserVerification, + WebAuthn.WebAuthnAuth memory webAuthnAuth, + bytes32 x, + bytes32 y + ) public virtual returns (bool) { + return WebAuthn.verify(challenge, requireUserVerification, webAuthnAuth, x, y); + } + + struct _TestTemps { + bytes32 x; + bytes32 y; + bytes challenge; + } + + function _testTemps() internal virtual returns (_TestTemps memory t) { + t.x = 0x3f2be075ef57d6c8374ef412fe54fdd980050f70f4f3a00b5b1b32d2def7d28d; + t.y = 0x57095a365acc2590ade3583fabfe8fbd64a9ed3ec07520da00636fb21f0176c1; + t.challenge = abi.encode(0xf631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf); + } + + function testSafari() public { + _etchRIPPrecompile(true); + _etchVerifier(true); + _TestTemps memory t = _testTemps(); + WebAuthn.WebAuthnAuth memory auth; + auth.authenticatorData = + hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000101"; + auth.clientDataJSON = string( + abi.encodePacked( + '{"type":"webauthn.get","challenge":"', + Base64.encode(t.challenge, true, true), + '","origin":"http://localhost:3005"}' + ) + ); + auth.challengeIndex = 23; + auth.typeIndex = 1; + auth.r = 0x60946081650523acad13c8eff94996a409b1ed60e923c90f9e366aad619adffa; + auth.s = 0x3216a237b73765d01b839e0832d73474bc7e63f4c86ef05fbbbfbeb34b35602b; + assertTrue(WebAuthn.verify(t.challenge, false, auth, t.x, t.y)); + } + + function testChrome() public { + _etchRIPPrecompile(true); + _etchVerifier(true); + _TestTemps memory t = _testTemps(); + WebAuthn.WebAuthnAuth memory auth; + auth.authenticatorData = + hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763050000010a"; + auth.clientDataJSON = string( + abi.encodePacked( + '{"type":"webauthn.get","challenge":"', + Base64.encode(t.challenge, true, true), + '","origin":"http://localhost:3005","crossOrigin":false}' + ) + ); + auth.challengeIndex = 23; + auth.typeIndex = 1; + auth.r = 0x41c01ca5ecdfeb23ef70d6cc216fd491ac3aa3d40c480751f3618a3a9ef67b41; + auth.s = 0x6595569abf76c2777e832a9252bae14efdb77febd0fa3b919aa16f6208469e86; + assertTrue(WebAuthn.verify(t.challenge, false, auth, t.x, t.y)); + } + + function testPassthroughDifferential(bytes32) public { + _etchVerifierPassthrough(true); + _etchRIPPrecompilePassthrough(true); + + bytes memory challenge = _sampleRandomUniformShortBytes(); + WebAuthn.WebAuthnAuth memory auth; + auth.authenticatorData = _sampleRandomUniformShortBytes(); + auth.clientDataJSON = _sampleClientDataJSON(challenge); + auth.challengeIndex = _sampleChallengeIndex(auth.clientDataJSON); + auth.typeIndex = _sampleTypeIndex(auth.clientDataJSON); + bool requireUserVerification = _randomChance(2); + assertEq( + WebAuthn.verify(challenge, requireUserVerification, auth, 0, 0), + _verifyPassthroughOriginal(challenge, requireUserVerification, auth) + ); + } + + bytes1 private constant _AUTH_DATA_FLAGS_UP = 0x01; + bytes1 private constant _AUTH_DATA_FLAGS_UV = 0x04; + bytes32 private constant _EXPECTED_TYPE_HASH = keccak256('"type":"webauthn.get"'); + + function _verifyPassthroughOriginal( + bytes memory challenge, + bool requireUserVerification, + WebAuthn.WebAuthnAuth memory webAuthnAuth + ) internal pure returns (bool) { + string memory t = LibString.slice( + webAuthnAuth.clientDataJSON, webAuthnAuth.typeIndex, webAuthnAuth.typeIndex + 21 + ); + if (keccak256(bytes(t)) != _EXPECTED_TYPE_HASH) { + return false; + } + bytes memory expectedChallenge = + abi.encodePacked('"challenge":"', Base64.encode(challenge, true, true), '"'); + string memory actualChallenge = LibString.slice( + webAuthnAuth.clientDataJSON, + webAuthnAuth.challengeIndex, + webAuthnAuth.challengeIndex + expectedChallenge.length + ); + if (keccak256(bytes(actualChallenge)) != keccak256(expectedChallenge)) { + return false; + } + + if (webAuthnAuth.authenticatorData.length <= 32) return false; + + if (webAuthnAuth.authenticatorData[32] & _AUTH_DATA_FLAGS_UP != _AUTH_DATA_FLAGS_UP) { + return false; + } + if ( + requireUserVerification + && (webAuthnAuth.authenticatorData[32] & _AUTH_DATA_FLAGS_UV) != _AUTH_DATA_FLAGS_UV + ) { + return false; + } + return true; + } + + function _sampleClientDataJSON(bytes memory challenge) internal returns (string memory) { + return string( + abi.encodePacked( + "{", + _sampleRandomUniformShortBytes(), + _maybeReturnEmpty('"type":"webauthn.get"'), + _sampleRandomUniformShortBytes(), + _maybeReturnEmpty(',"challenge":"'), + Base64.encode(challenge, true, true), + _maybeReturnEmpty(abi.encodePacked('"', _sampleRandomUniformShortBytes(), "}")) + ) + ); + } + + function _maybeReturnEmpty(bytes memory s) internal returns (bytes memory result) { + if (!_randomChance(4)) result = s; + } + + function _sampleChallengeIndex(string memory clientDataJSON) + internal + returns (uint256 result) + { + if (!_randomChance(4)) { + result = LibString.indexOf(clientDataJSON, '"challenge":"'); + if (result <= 0xffffffff) return result; + } + unchecked { + result = _bound(_randomUniform(), 0, bytes(clientDataJSON).length + 35); + } + } + + function _sampleTypeIndex(string memory clientDataJSON) internal returns (uint256 result) { + if (!_randomChance(4)) { + result = LibString.indexOf(clientDataJSON, '"type":"webauthn.get"'); + if (result <= 0xffffffff) return result; + } + unchecked { + result = _bound(_randomUniform(), 0, bytes(clientDataJSON).length + 35); + } + } + + function _sampleRandomUniformShortBytes() internal returns (bytes memory result) { + uint256 n = _randomUniform(); + uint256 r = _randomUniform(); + /// @solidity memory-safe-assembly + assembly { + switch and(0xf, byte(0, n)) + case 0 { n := and(n, 0x3f) } + default { n := and(n, 0x3) } + result := mload(0x40) + mstore(result, n) + mstore(add(0x20, result), r) + mstore(add(0x40, result), keccak256(result, 0x40)) + mstore(0x40, add(result, 0x80)) + } + } + + function testTryDecodeAuth(bytes32) public { + WebAuthn.WebAuthnAuth memory auth; + auth.authenticatorData = _sampleRandomUniformShortBytes(); + auth.clientDataJSON = string(_sampleRandomUniformShortBytes()); + auth.challengeIndex = _randomUniform(); + auth.typeIndex = _randomUniform(); + auth.r = bytes32(_randomUniform()); + auth.s = bytes32(_randomUniform()); + bytes memory encoded = abi.encode(auth); + WebAuthn.WebAuthnAuth memory decoded = WebAuthn.tryDecodeAuth(encoded); + assertEq(decoded.authenticatorData, auth.authenticatorData); + assertEq(decoded.clientDataJSON, auth.clientDataJSON); + assertEq(decoded.challengeIndex, auth.challengeIndex); + assertEq(decoded.typeIndex, auth.typeIndex); + assertEq(decoded.r, auth.r); + assertEq(decoded.s, auth.s); + } + + function testTryDecodeAuthCompact(bytes32) public { + WebAuthn.WebAuthnAuth memory auth; + auth.authenticatorData = _sampleRandomUniformShortBytes(); + auth.clientDataJSON = string(_sampleRandomUniformShortBytes()); + auth.challengeIndex = uint16(_randomUniform()); + auth.typeIndex = uint16(_randomUniform()); + auth.r = bytes32(_randomUniform()); + auth.s = bytes32(_randomUniform()); + bytes memory encoded = WebAuthn.tryEncodeAuthCompact(auth); + WebAuthn.WebAuthnAuth memory decoded = WebAuthn.tryDecodeAuthCompact(encoded); + assertEq(decoded.authenticatorData, auth.authenticatorData); + assertEq(decoded.clientDataJSON, auth.clientDataJSON); + assertEq(decoded.challengeIndex, auth.challengeIndex); + assertEq(decoded.typeIndex, auth.typeIndex); + assertEq(decoded.r, auth.r); + assertEq(decoded.s, auth.s); + } +}