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

Add Base64Url encoding #4822

Merged
merged 17 commits into from
Jan 16, 2024
5 changes: 5 additions & 0 deletions .changeset/twenty-feet-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`Base64`: Add `encodeURL` following section 5 of RFC4648 for URL encoding
53 changes: 37 additions & 16 deletions contracts/utils/Base64.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
29 changes: 24 additions & 5 deletions test/utils/Base64.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -12,18 +16,33 @@ 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 (special case)', input: 'où', expected: 'b/k=' },
{ 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 (special case)', input: 'où', expected: 'b_k' },
{ 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);
});
});
});
Loading