Skip to content

Commit 1e174b6

Browse files
Amxxernestognw
andauthored
Refactor WebAuthn (#194)
Co-authored-by: ernestognw <[email protected]>
1 parent 1451a5d commit 1e174b6

File tree

5 files changed

+221
-404
lines changed

5 files changed

+221
-404
lines changed

contracts/utils/cryptography/WebAuthn.sol

Lines changed: 73 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -52,43 +52,58 @@ library WebAuthn {
5252
}
5353

5454
/// @dev Bit 0 of the authenticator data flags: "User Present" bit.
55-
bytes1 private constant AUTH_DATA_FLAGS_UP = 0x01;
55+
bytes1 internal constant AUTH_DATA_FLAGS_UP = 0x01;
5656
/// @dev Bit 2 of the authenticator data flags: "User Verified" bit.
57-
bytes1 private constant AUTH_DATA_FLAGS_UV = 0x04;
57+
bytes1 internal constant AUTH_DATA_FLAGS_UV = 0x04;
5858
/// @dev Bit 3 of the authenticator data flags: "Backup Eligibility" bit.
59-
bytes1 private constant AUTH_DATA_FLAGS_BE = 0x08;
59+
bytes1 internal constant AUTH_DATA_FLAGS_BE = 0x08;
6060
/// @dev Bit 4 of the authenticator data flags: "Backup State" bit.
61-
bytes1 private constant AUTH_DATA_FLAGS_BS = 0x10;
61+
bytes1 internal constant AUTH_DATA_FLAGS_BS = 0x10;
6262

6363
/**
64-
* @dev Performs the absolute minimal verification of a WebAuthn Authentication Assertion.
65-
* This function includes only the essential checks required for basic WebAuthn security:
66-
*
67-
* 1. Type is "webauthn.get" (see {validateExpectedTypeHash})
68-
* 2. Challenge matches the expected value (see {validateChallenge})
69-
* 3. Cryptographic signature is valid for the given public key
64+
* @dev Performs standard verification of a WebAuthn Authentication Assertion.
65+
*/
66+
function verify(
67+
bytes memory challenge,
68+
WebAuthnAuth memory auth,
69+
bytes32 qx,
70+
bytes32 qy
71+
) internal view returns (bool) {
72+
return verify(challenge, auth, qx, qy, true);
73+
}
74+
75+
/**
76+
* @dev Performs verification of a WebAuthn Authentication Assertion. This variants allow the caller to select
77+
* whether of not to require the UV flag (step 17).
7078
*
71-
* For most applications, use {verify} or {verifyStrict} instead.
79+
* Verifies:
7280
*
73-
* NOTE: This function intentionally omits User Presence (UP), User Verification (UV),
74-
* and Backup State/Eligibility checks. Use this only when broader compatibility with
75-
* authenticators is required or in constrained environments.
81+
* 1. Type is "webauthn.get" (see {_validateExpectedTypeHash})
82+
* 2. Challenge matches the expected value (see {_validateChallenge})
83+
* 3. Cryptographic signature is valid for the given public key
84+
* 4. confirming physical user presence during authentication
85+
* 5. (if `requireUV` is true) confirming stronger user authentication (biometrics/PIN)
86+
* 6. Backup Eligibility (`BE`) and Backup State (BS) bits relationship is valid
7687
*/
77-
function verifyMinimal(
88+
function verify(
7889
bytes memory challenge,
7990
WebAuthnAuth memory auth,
8091
bytes32 qx,
81-
bytes32 qy
92+
bytes32 qy,
93+
bool requireUV
8294
) internal view returns (bool) {
8395
// Verify authenticator data has sufficient length (37 bytes minimum):
8496
// - 32 bytes for rpIdHash
8597
// - 1 byte for flags
8698
// - 4 bytes for signature counter
8799
return
88100
auth.authenticatorData.length > 36 &&
89-
validateExpectedTypeHash(auth.clientDataJSON, auth.typeIndex) && // 11
90-
validateChallenge(auth.clientDataJSON, auth.challengeIndex, challenge) && // 12
91-
// Handles signature malleability internally
101+
_validateExpectedTypeHash(auth.clientDataJSON, auth.typeIndex) && // 11
102+
_validateChallenge(auth.clientDataJSON, auth.challengeIndex, challenge) && // 12
103+
_validateUserPresentBitSet(auth.authenticatorData[32]) && // 16
104+
(!requireUV || _validateUserVerifiedBitSet(auth.authenticatorData[32])) && // 17
105+
_validateBackupEligibilityAndState(auth.authenticatorData[32]) && // Consistency check
106+
// P256.verify handles signature malleability internally
92107
P256.verify(
93108
sha256(
94109
abi.encodePacked(
@@ -104,69 +119,63 @@ library WebAuthn {
104119
}
105120

106121
/**
107-
* @dev Performs standard verification of a WebAuthn Authentication Assertion.
122+
* @dev Validates that the https://www.w3.org/TR/webauthn-2/#type[Type] field in the client data JSON is set to
123+
* "webauthn.get".
108124
*
109-
* Same as {verifyMinimal}, but also verifies:
110-
*
111-
* [start=4]
112-
* 4. {validateUserPresentBitSet} - confirming physical user presence during authentication
113-
*
114-
* This compliance level satisfies the core WebAuthn verification requirements while
115-
* maintaining broad compatibility with authenticators. For higher security requirements,
116-
* consider using {verifyStrict}.
125+
* Step 11 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
117126
*/
118-
function verify(
119-
bytes memory challenge,
120-
WebAuthnAuth memory auth,
121-
bytes32 qx,
122-
bytes32 qy
123-
) internal view returns (bool) {
124-
// 16 && rest
125-
return validateUserPresentBitSet(auth.authenticatorData[32]) && verifyMinimal(challenge, auth, qx, qy);
127+
function _validateExpectedTypeHash(
128+
string memory clientDataJSON,
129+
uint256 typeIndex
130+
) private pure returns (bool success) {
131+
assembly ("memory-safe") {
132+
success := and(
133+
// clientDataJson.length >= typeIndex + 21
134+
gt(mload(clientDataJSON), add(typeIndex, 20)),
135+
eq(
136+
// get 32 bytes starting at index typexIndex in clientDataJSON, and keep the leftmost 21 bytes
137+
and(mload(add(add(clientDataJSON, 0x20), typeIndex)), shl(88, not(0))),
138+
// solhint-disable-next-line quotes
139+
'"type":"webauthn.get"'
140+
)
141+
)
142+
}
126143
}
127144

128145
/**
129-
* @dev Performs strict verification of a WebAuthn Authentication Assertion.
130-
*
131-
* Same as {verify}, but also also verifies:
132-
*
133-
* [start=5]
134-
* 5. {validateUserVerifiedBitSet} - confirming stronger user authentication (biometrics/PIN)
135-
* 6. {validateBackupEligibilityAndState}- Backup Eligibility (`BE`) and Backup State (BS) bits
136-
* relationship is valid
146+
* @dev Validates that the challenge in the client data JSON matches the `expectedChallenge`.
137147
*
138-
* This strict verification is recommended for:
139-
*
140-
* * High-value transactions
141-
* * Privileged operations
142-
* * Account recovery or critical settings changes
143-
* * Applications where security takes precedence over broad authenticator compatibility
148+
* Step 12 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
144149
*/
145-
function verifyStrict(
146-
bytes memory challenge,
147-
WebAuthnAuth memory auth,
148-
bytes32 qx,
149-
bytes32 qy
150-
) internal view returns (bool) {
151-
return
152-
validateUserVerifiedBitSet(auth.authenticatorData[32]) && // 17
153-
validateBackupEligibilityAndState(auth.authenticatorData[32]) && // Consistency check
154-
verify(challenge, auth, qx, qy);
150+
function _validateChallenge(
151+
string memory clientDataJSON,
152+
uint256 challengeIndex,
153+
bytes memory challenge
154+
) private pure returns (bool) {
155+
// solhint-disable-next-line quotes
156+
string memory expectedChallenge = string.concat('"challenge":"', Base64.encodeURL(challenge), '"');
157+
string memory actualChallenge = string(
158+
Bytes.slice(bytes(clientDataJSON), challengeIndex, challengeIndex + bytes(expectedChallenge).length)
159+
);
160+
161+
return Strings.equal(actualChallenge, expectedChallenge);
155162
}
156163

157164
/**
158165
* @dev Validates that the https://www.w3.org/TR/webauthn-2/#up[User Present (UP)] bit is set.
166+
*
159167
* Step 16 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
160168
*
161169
* NOTE: Required by WebAuthn spec but may be skipped for platform authenticators
162170
* (Touch ID, Windows Hello) in controlled environments. Enforce for public-facing apps.
163171
*/
164-
function validateUserPresentBitSet(bytes1 flags) internal pure returns (bool) {
172+
function _validateUserPresentBitSet(bytes1 flags) private pure returns (bool) {
165173
return (flags & AUTH_DATA_FLAGS_UP) == AUTH_DATA_FLAGS_UP;
166174
}
167175

168176
/**
169177
* @dev Validates that the https://www.w3.org/TR/webauthn-2/#uv[User Verified (UV)] bit is set.
178+
*
170179
* Step 17 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
171180
*
172181
* The UV bit indicates whether the user was verified using a stronger identification method
@@ -180,7 +189,7 @@ library WebAuthn {
180189
* `UV=0` may be acceptable. The choice of whether to require UV represents a security vs. usability
181190
* tradeoff - for blockchain applications handling valuable assets, requiring UV is generally safer.
182191
*/
183-
function validateUserVerifiedBitSet(bytes1 flags) internal pure returns (bool) {
192+
function _validateUserVerifiedBitSet(bytes1 flags) private pure returns (bool) {
184193
return (flags & AUTH_DATA_FLAGS_UV) == AUTH_DATA_FLAGS_UV;
185194
}
186195

@@ -207,35 +216,8 @@ library WebAuthn {
207216
* compatibility or when the application's threat model doesn't consider credential
208217
* syncing a major risk.
209218
*/
210-
function validateBackupEligibilityAndState(bytes1 flags) internal pure returns (bool) {
211-
return (flags & AUTH_DATA_FLAGS_BE) != 0 || (flags & AUTH_DATA_FLAGS_BS) == 0;
212-
}
213-
214-
/**
215-
* @dev Validates that the https://www.w3.org/TR/webauthn-2/#type[Type] field in the client data JSON
216-
* is set to "webauthn.get".
217-
*/
218-
function validateExpectedTypeHash(string memory clientDataJSON, uint256 typeIndex) internal pure returns (bool) {
219-
// 21 = length of '"type":"webauthn.get"'
220-
bytes memory typeValueBytes = Bytes.slice(bytes(clientDataJSON), typeIndex, typeIndex + 21);
221-
222-
// solhint-disable-next-line quotes
223-
return bytes21(typeValueBytes) == bytes21('"type":"webauthn.get"');
224-
}
225-
226-
/// @dev Validates that the challenge in the client data JSON matches the `expectedChallenge`.
227-
function validateChallenge(
228-
string memory clientDataJSON,
229-
uint256 challengeIndex,
230-
bytes memory challenge
231-
) internal pure returns (bool) {
232-
// solhint-disable-next-line quotes
233-
string memory expectedChallenge = string.concat('"challenge":"', Base64.encodeURL(challenge), '"');
234-
string memory actualChallenge = string(
235-
Bytes.slice(bytes(clientDataJSON), challengeIndex, challengeIndex + bytes(expectedChallenge).length)
236-
);
237-
238-
return Strings.equal(actualChallenge, expectedChallenge);
219+
function _validateBackupEligibilityAndState(bytes1 flags) private pure returns (bool) {
220+
return (flags & AUTH_DATA_FLAGS_BE) == AUTH_DATA_FLAGS_BE || (flags & AUTH_DATA_FLAGS_BS) == 0;
239221
}
240222

241223
/**

contracts/utils/cryptography/signers/SignerWebAuthn.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ abstract contract SignerWebAuthn is SignerP256 {
4444

4545
return
4646
decodeSuccess
47-
? WebAuthn.verifyMinimal(abi.encodePacked(hash), auth, qx, qy)
47+
? WebAuthn.verify(abi.encodePacked(hash), auth, qx, qy)
4848
: super._rawSignatureValidation(hash, signature);
4949
}
5050
}

contracts/utils/cryptography/verifiers/ERC7913WebAuthnVerifier.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ contract ERC7913WebAuthnVerifier is IERC7913SignatureVerifier {
2525
return
2626
decodeSuccess &&
2727
key.length == 0x40 &&
28-
WebAuthn.verifyMinimal(abi.encodePacked(hash), auth, bytes32(key[0x00:0x20]), bytes32(key[0x20:0x40]))
28+
WebAuthn.verify(abi.encodePacked(hash), auth, bytes32(key[0x00:0x20]), bytes32(key[0x20:0x40]))
2929
? IERC7913SignatureVerifier.verify.selector
3030
: bytes4(0xFFFFFFFF);
3131
}

test/helpers/signers.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
const { P256SigningKey } = require('@openzeppelin/contracts/test/helpers/signers');
22
const {
33
AbiCoder,
4+
ZeroHash,
45
assertArgument,
56
concat,
67
dataLength,
78
sha256,
8-
toBeHex,
9+
solidityPacked,
910
toBigInt,
1011
encodeBase64,
1112
toUtf8Bytes,
@@ -86,7 +87,8 @@ class WebAuthnSigningKey extends P256SigningKey {
8687
challenge: encodeBase64(digest).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''),
8788
});
8889

89-
const authenticatorData = toBeHex(0n, 37); // equivalent to `hexlify(new Uint8Array(37))`
90+
// Flags 0x05 = AUTH_DATA_FLAGS_UP | AUTH_DATA_FLAGS_UV
91+
const authenticatorData = solidityPacked(['bytes32', 'bytes1', 'bytes4'], [ZeroHash, '0x05', '0x00000000']);
9092

9193
// Regular P256 signature
9294
const { r, s } = super.sign(sha256(concat([authenticatorData, sha256(toUtf8Bytes(clientDataJSON))])));

0 commit comments

Comments
 (0)