diff --git a/.changeset/twenty-feet-grin.md b/.changeset/twenty-feet-grin.md new file mode 100644 index 00000000000..69b4fe63b2e --- /dev/null +++ b/.changeset/twenty-feet-grin.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`Base64`: Add `encodeURL` following section 5 of RFC4648 for URL encoding diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 96861e13abf..9a5779d51b6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -83,7 +83,8 @@ jobs: - name: Set up environment uses: ./.github/actions/setup - name: Run tests - run: forge test -vv + # Base64Test requires `--ffi`. See test/utils/Base64.t.sol + run: forge test -vv --no-match-contract Base64Test coverage: runs-on: ubuntu-latest diff --git a/contracts/utils/Base64.sol b/contracts/utils/Base64.sol index f8547d1cc88..3f2d7c134b3 100644 --- a/contracts/utils/Base64.sol +++ b/contracts/utils/Base64.sol @@ -9,29 +9,48 @@ pragma solidity ^0.8.20; library Base64 { /** * @dev Base64 Encoding/Decoding Table + * See sections 4 and 5 of https://datatracker.ietf.org/doc/html/rfc4648 */ string internal constant _TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + string internal constant _TABLE_URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; /** * @dev Converts a `bytes` to its Bytes64 `string` representation. */ function encode(bytes memory data) internal pure returns (string memory) { + return _encode(data, _TABLE, true); + } + + /** + * @dev Converts a `bytes` to its Bytes64Url `string` representation. + */ + function encodeURL(bytes memory data) internal pure returns (string memory) { + return _encode(data, _TABLE_URL, false); + } + + /** + * @dev Internal table-agnostic conversion + */ + function _encode(bytes memory data, string memory table, bool withPadding) private pure returns (string memory) { /** * Inspired by Brecht Devos (Brechtpd) implementation - MIT licence * https://github.com/Brechtpd/base64/blob/e78d9fd951e7b0977ddca77d92dc85183770daf4/base64.sol */ if (data.length == 0) return ""; - // Loads the table into memory - string memory table = _TABLE; - - // Encoding takes 3 bytes chunks of binary data from `bytes` data parameter - // and split into 4 numbers of 6 bits. - // The final Base64 length should be `bytes` data length multiplied by 4/3 rounded up + // If padding is enabled, the final length should be `bytes` data length divided by 3 rounded up and then + // multiplied by 4 so that it leaves room for padding the last chunk // - `data.length + 2` -> Round up // - `/ 3` -> Number of 3-bytes chunks // - `4 *` -> 4 characters for each chunk - string memory result = new string(4 * ((data.length + 2) / 3)); + // If padding is disabled, the final length should be `bytes` data length multiplied by 4/3 rounded up as + // opposed to when padding is required to fill the last chunk. + // - `4 *` -> 4 characters for each chunk + // - `data.length + 2` -> Round up + // - `/ 3` -> Number of 3-bytes chunks + uint256 resultLength = withPadding ? 4 * ((data.length + 2) / 3) : (4 * data.length + 2) / 3; + + string memory result = new string(resultLength); /// @solidity memory-safe-assembly assembly { @@ -73,15 +92,17 @@ library Base64 { resultPtr := add(resultPtr, 1) // Advance } - // When data `bytes` is not exactly 3 bytes long - // it is padded with `=` characters at the end - switch mod(mload(data), 3) - case 1 { - mstore8(sub(resultPtr, 1), 0x3d) - mstore8(sub(resultPtr, 2), 0x3d) - } - case 2 { - mstore8(sub(resultPtr, 1), 0x3d) + if withPadding { + // When data `bytes` is not exactly 3 bytes long + // it is padded with `=` characters at the end + switch mod(mload(data), 3) + case 1 { + mstore8(sub(resultPtr, 1), 0x3d) + mstore8(sub(resultPtr, 2), 0x3d) + } + case 2 { + mstore8(sub(resultPtr, 1), 0x3d) + } } } diff --git a/scripts/tests/base64.sh b/scripts/tests/base64.sh new file mode 100644 index 00000000000..f51cb4002e2 --- /dev/null +++ b/scripts/tests/base64.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +_encode() { + # - Print the input to stdout + # - Remove the first two characters + # - Convert from hex to binary + # - Convert from binary to base64 + # - Remove newlines from `base64` output + echo -n "$1" | cut -c 3- | xxd -r -p | base64 | tr -d \\n +} + +encode() { + # - Convert from base64 to hex + # - Remove newlines from `xxd` output + _encode "$1" | xxd -p | tr -d \\n +} + +encodeURL() { + # - Remove padding from `base64` output + # - Replace `+` with `-` + # - Replace `/` with `_` + # - Convert from base64 to hex + # - Remove newlines from `xxd` output + _encode "$1" | sed 's/=//g' | sed 's/+/-/g' | sed 's/\//_/g' | xxd -p | tr -d \\n +} + +# $1: function name +# $2: input +$1 $2 diff --git a/test/utils/Base64.t.sol b/test/utils/Base64.t.sol new file mode 100644 index 00000000000..80e4f49df9c --- /dev/null +++ b/test/utils/Base64.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; + +/// NOTE: This test requires `ffi` to be enabled. It does not run in the CI +/// environment given `ffi` is not recommended. +/// See: https://github.com/foundry-rs/foundry/issues/6744 +contract Base64Test is Test { + function testEncode(bytes memory input) external { + string memory output = Base64.encode(input); + assertEq(output, _base64Ffi(input, "encode")); + } + + function testEncodeURL(bytes memory input) external { + string memory output = Base64.encodeURL(input); + assertEq(output, _base64Ffi(input, "encodeURL")); + } + + function _base64Ffi(bytes memory input, string memory fn) internal returns (string memory) { + string[] memory command = new string[](4); + command[0] = "bash"; + command[1] = "scripts/tests/base64.sh"; + command[2] = fn; + command[3] = vm.toString(input); + bytes memory retData = vm.ffi(command); + return string(retData); + } +} diff --git a/test/utils/Base64.test.js b/test/utils/Base64.test.js index 4707db0c349..3f3e9fc8beb 100644 --- a/test/utils/Base64.test.js +++ b/test/utils/Base64.test.js @@ -2,6 +2,10 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +// Replace "+/" with "-_" in the char table, and remove the padding +// see https://datatracker.ietf.org/doc/html/rfc4648#section-5 +const base64toBase64Url = str => str.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + async function fixture() { const mock = await ethers.deployContract('$Base64'); return { mock }; @@ -12,18 +16,35 @@ describe('Strings', function () { Object.assign(this, await loadFixture(fixture)); }); - describe('from bytes - base64', function () { + describe('base64', function () { for (const { title, input, expected } of [ { title: 'converts to base64 encoded string with double padding', input: 'test', expected: 'dGVzdA==' }, { title: 'converts to base64 encoded string with single padding', input: 'test1', expected: 'dGVzdDE=' }, { title: 'converts to base64 encoded string without padding', input: 'test12', expected: 'dGVzdDEy' }, - { title: 'empty bytes', input: '0x', expected: '' }, + { title: 'converts to base64 encoded string (/ case)', input: 'où', expected: 'b/k=' }, + { title: 'converts to base64 encoded string (+ case)', input: 'zs~1t8', expected: 'enN+MXQ4' }, + { title: 'empty bytes', input: '', expected: '' }, ]) it(title, async function () { - const raw = ethers.isBytesLike(input) ? input : ethers.toUtf8Bytes(input); + const buffer = Buffer.from(input, 'ascii'); + expect(await this.mock.$encode(buffer)).to.equal(ethers.encodeBase64(buffer)); + expect(await this.mock.$encode(buffer)).to.equal(expected); + }); + }); - expect(await this.mock.$encode(raw)).to.equal(ethers.encodeBase64(raw)); - expect(await this.mock.$encode(raw)).to.equal(expected); + describe('base64url', function () { + for (const { title, input, expected } of [ + { title: 'converts to base64url encoded string with double padding', input: 'test', expected: 'dGVzdA' }, + { title: 'converts to base64url encoded string with single padding', input: 'test1', expected: 'dGVzdDE' }, + { title: 'converts to base64url encoded string without padding', input: 'test12', expected: 'dGVzdDEy' }, + { title: 'converts to base64url encoded string (_ case)', input: 'où', expected: 'b_k' }, + { title: 'converts to base64url encoded string (- case)', input: 'zs~1t8', expected: 'enN-MXQ4' }, + { title: 'empty bytes', input: '', expected: '' }, + ]) + it(title, async function () { + const buffer = Buffer.from(input, 'ascii'); + expect(await this.mock.$encodeURL(buffer)).to.equal(base64toBase64Url(ethers.encodeBase64(buffer))); + expect(await this.mock.$encodeURL(buffer)).to.equal(expected); }); }); });