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`: And `encodeUrl` following section 5 of rfc4648 for URL encoding
Amxx marked this conversation as resolved.
Show resolved Hide resolved
66 changes: 66 additions & 0 deletions contracts/utils/Base64.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ 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.
Expand Down Expand Up @@ -87,4 +89,68 @@ library Base64 {

return result;
}

/**
* @dev Converts a `bytes` to its Bytes64Url `string` representation.
*/
function encodeUrl(bytes memory data) internal pure returns (string memory) {
Amxx marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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_URL;

// Encoding takes 3 bytes chunks of binary data from `bytes` data parameter
// and split into 4 numbers of 6 bits.
// Rounding here is different from the one performed in {encode} because we don't want padding
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
string memory result = new string((4 * data.length + 2) / 3);

/// @solidity memory-safe-assembly
assembly {
// Prepare the lookup table (skip the first "length" byte)
let tablePtr := add(table, 1)

// Prepare result pointer, jump over length
let resultPtr := add(result, 32)

// Run over the input, 3 bytes at a time
for {
let dataPtr := data
let endPtr := add(data, mload(data))
} lt(dataPtr, endPtr) {

} {
// Advance 3 bytes
dataPtr := add(dataPtr, 3)
let input := mload(dataPtr)

// To write each character, shift the 3 bytes (18 bits) chunk
// 4 times in blocks of 6 bits for each character (18, 12, 6, 0)
// and apply logical AND with 0x3F which is the number of
// the previous character in the ASCII table prior to the Base64 Table
// The result is then added to the table to get the character to write,
// and finally write it in the result pointer but with a left shift
// of 256 (1 byte) - 8 (1 ASCII char) = 248 bits

mstore8(resultPtr, mload(add(tablePtr, and(shr(18, input), 0x3F))))
resultPtr := add(resultPtr, 1) // Advance

mstore8(resultPtr, mload(add(tablePtr, and(shr(12, input), 0x3F))))
resultPtr := add(resultPtr, 1) // Advance

mstore8(resultPtr, mload(add(tablePtr, and(shr(6, input), 0x3F))))
resultPtr := add(resultPtr, 1) // Advance

mstore8(resultPtr, mload(add(tablePtr, and(input, 0x3F))))
resultPtr := add(resultPtr, 1) // Advance
}

// No `=` padding here
}

return result;
}
}
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