Skip to content

Commit b7220cc

Browse files
Amxxernestognw
andauthored
Decode WebAuthn object from calldata buffers, with out-of-bound detection (#191)
Co-authored-by: Ernesto García <[email protected]>
1 parent b6a5937 commit b7220cc

File tree

3 files changed

+65
-49
lines changed

3 files changed

+65
-49
lines changed

contracts/utils/cryptography/WebAuthn.sol

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,6 @@ library WebAuthn {
6060
/// @dev Bit 4 of the authenticator data flags: "Backup State" bit.
6161
bytes1 private constant AUTH_DATA_FLAGS_BS = 0x10;
6262

63-
/// @dev The expected type string in the client data JSON when verifying assertion signatures.
64-
/// https://www.w3.org/TR/webauthn-2/#dom-collectedclientdata-type
65-
// solhint-disable-next-line quotes
66-
bytes32 private constant EXPECTED_TYPE_HASH = keccak256('"type":"webauthn.get"');
67-
6863
/**
6964
* @dev Performs the absolute minimal verification of a WebAuthn Authentication Assertion.
7065
* This function includes only the essential checks required for basic WebAuthn security:
@@ -89,18 +84,16 @@ library WebAuthn {
8984
// - 32 bytes for rpIdHash
9085
// - 1 byte for flags
9186
// - 4 bytes for signature counter
92-
if (auth.authenticatorData.length < 37) return false;
93-
bytes memory clientDataJSON = bytes(auth.clientDataJSON);
94-
9587
return
96-
validateExpectedTypeHash(clientDataJSON, auth.typeIndex) && // 11
97-
validateChallenge(clientDataJSON, auth.challengeIndex, challenge) && // 12
88+
auth.authenticatorData.length > 36 &&
89+
validateExpectedTypeHash(auth.clientDataJSON, auth.typeIndex) && // 11
90+
validateChallenge(auth.clientDataJSON, auth.challengeIndex, challenge) && // 12
9891
// Handles signature malleability internally
9992
P256.verify(
10093
sha256(
10194
abi.encodePacked(
10295
auth.authenticatorData,
103-
sha256(clientDataJSON) // 19
96+
sha256(bytes(auth.clientDataJSON)) // 19
10497
)
10598
),
10699
auth.r,
@@ -222,29 +215,64 @@ library WebAuthn {
222215
* @dev Validates that the https://www.w3.org/TR/webauthn-2/#type[Type] field in the client data JSON
223216
* is set to "webauthn.get".
224217
*/
225-
function validateExpectedTypeHash(bytes memory clientDataJSON, uint256 typeIndex) internal pure returns (bool) {
218+
function validateExpectedTypeHash(string memory clientDataJSON, uint256 typeIndex) internal pure returns (bool) {
226219
// 21 = length of '"type":"webauthn.get"'
227-
bytes memory typeValueBytes = Bytes.slice(clientDataJSON, typeIndex, typeIndex + 21);
228-
return keccak256(typeValueBytes) == EXPECTED_TYPE_HASH;
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"');
229224
}
230225

231226
/// @dev Validates that the challenge in the client data JSON matches the `expectedChallenge`.
232227
function validateChallenge(
233-
bytes memory clientDataJSON,
228+
string memory clientDataJSON,
234229
uint256 challengeIndex,
235-
bytes memory expectedChallenge
230+
bytes memory challenge
236231
) internal pure returns (bool) {
237-
bytes memory expectedChallengeBytes = bytes(
238-
// solhint-disable-next-line quotes
239-
string.concat('"challenge":"', Base64.encodeURL(expectedChallenge), '"')
240-
);
241-
if (challengeIndex + expectedChallengeBytes.length > clientDataJSON.length) return false;
242-
bytes memory actualChallengeBytes = Bytes.slice(
243-
clientDataJSON,
244-
challengeIndex,
245-
challengeIndex + expectedChallengeBytes.length
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)
246236
);
247237

248-
return Strings.equal(string(actualChallengeBytes), string(expectedChallengeBytes));
238+
return Strings.equal(actualChallenge, expectedChallenge);
239+
}
240+
241+
/**
242+
* @dev Verifies that calldata bytes (`input`) represents a valid `WebAuthnAuth` object. If encoding is valid,
243+
* returns true and the calldata view at the object. Otherwise, returns false and an invalid calldata object.
244+
*
245+
* NOTE: The returned `auth` object should not be accessed if `success` is false. Trying to access the data may
246+
* cause revert/panic.
247+
*/
248+
function tryDecodeAuth(bytes calldata input) internal pure returns (bool success, WebAuthnAuth calldata auth) {
249+
assembly ("memory-safe") {
250+
auth := input.offset
251+
}
252+
253+
// Minimum length to hold 6 objects (32 bytes each)
254+
if (input.length < 0xC0) return (false, auth);
255+
256+
// Get offset of non-value-type elements relative to the input buffer
257+
uint256 authenticatorDataOffset = uint256(bytes32(input[0x80:]));
258+
uint256 clientDataJSONOffset = uint256(bytes32(input[0xa0:]));
259+
260+
// The elements length (at the offset) should be 32 bytes long. We check that this is within the
261+
// buffer bounds. Since we know input.length is at least 32, we can subtract with no overflow risk.
262+
if (input.length - 0x20 < authenticatorDataOffset || input.length - 0x20 < clientDataJSONOffset)
263+
return (false, auth);
264+
265+
// Get the lengths. offset + 32 is bounded by input.length so it does not overflow.
266+
uint256 authenticatorDataLength = uint256(bytes32(input[authenticatorDataOffset:]));
267+
uint256 clientDataJSONLength = uint256(bytes32(input[clientDataJSONOffset:]));
268+
269+
// Check that the input buffer is long enough to store the non-value-type elements
270+
// Since we know input.length is at least xxxOffset + 32, we can subtract with no overflow risk.
271+
if (
272+
input.length - authenticatorDataOffset - 0x20 < authenticatorDataLength ||
273+
input.length - clientDataJSONOffset - 0x20 < clientDataJSONLength
274+
) return (false, auth);
275+
276+
return (true, auth);
249277
}
250278
}

contracts/utils/cryptography/signers/SignerWebAuthn.sol

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,11 @@ abstract contract SignerWebAuthn is SignerP256 {
4040
bytes calldata signature
4141
) internal view virtual override returns (bool) {
4242
(bytes32 qx, bytes32 qy) = signer();
43+
(bool decodeSuccess, WebAuthn.WebAuthnAuth calldata auth) = WebAuthn.tryDecodeAuth(signature);
4344

4445
return
45-
WebAuthn.verifyMinimal(abi.encodePacked(hash), _toWebAuthnSignature(signature), qx, qy) ||
46-
super._rawSignatureValidation(hash, signature);
47-
}
48-
49-
/// @dev Non-reverting version of signature decoding.
50-
function _toWebAuthnSignature(bytes calldata signature) private pure returns (WebAuthn.WebAuthnAuth memory auth) {
51-
bool decodable;
52-
assembly ("memory-safe") {
53-
let offset := calldataload(signature.offset)
54-
// Validate the offset is within bounds and makes sense for a WebAuthnAuth struct
55-
// A valid offset should be 32 and point to data within the signature bounds
56-
decodable := and(eq(offset, 32), lt(add(offset, 0x80), signature.length))
57-
}
58-
return decodable ? abi.decode(signature, (WebAuthn.WebAuthnAuth)) : auth;
46+
decodeSuccess
47+
? WebAuthn.verifyMinimal(abi.encodePacked(hash), auth, qx, qy)
48+
: super._rawSignatureValidation(hash, signature);
5949
}
6050
}

test/helpers/signers.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,14 @@ class WebAuthnSigningKey extends P256SigningKey {
9292
const { r, s } = super.sign(sha256(concat([authenticatorData, sha256(toUtf8Bytes(clientDataJSON))])));
9393

9494
const serialized = AbiCoder.defaultAbiCoder().encode(
95-
['tuple(bytes32,bytes32,uint256,uint256,bytes,string)'],
95+
['bytes32', 'bytes32', 'uint256', 'uint256', 'bytes', 'string'],
9696
[
97-
[
98-
r,
99-
s,
100-
clientDataJSON.indexOf('"challenge"'),
101-
clientDataJSON.indexOf('"type"'),
102-
authenticatorData,
103-
clientDataJSON,
104-
],
97+
r,
98+
s,
99+
clientDataJSON.indexOf('"challenge"'),
100+
clientDataJSON.indexOf('"type"'),
101+
authenticatorData,
102+
clientDataJSON,
105103
],
106104
);
107105

0 commit comments

Comments
 (0)