diff --git a/docs/source/filters/hmac_sha256.md b/docs/source/filters/hmac_sha256.md new file mode 100644 index 0000000000..24e00140ee --- /dev/null +++ b/docs/source/filters/hmac_sha256.md @@ -0,0 +1,20 @@ +--- +title: hmac_sha256 +--- + +{% since %}vNEXT{% endsince %} + +Converts a string into an SHA-256 hash using a hash message authentication code (HMAC). The secret key is passed as the filter argument. The output is a lowercase hexadecimal string. + +Input + +```liquid +{%- assign secret_potion = 'Polyjuice' | hmac_sha256: 'Polina' -%} +My secret potion: {{ secret_potion }} +``` + +Output + +```text +My secret potion: 8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20 +``` diff --git a/docs/source/filters/overview.md b/docs/source/filters/overview.md index eee2cb0e2c..342fab0c52 100644 --- a/docs/source/filters/overview.md +++ b/docs/source/filters/overview.md @@ -16,5 +16,6 @@ Array | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_ Date | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string Misc | default, json, jsonify, inspect, raw, to_integer Base64 | base64_encode, base64_decode +Crypto | sha256, hmac_sha256 [shopify/liquid]: https://github.com/Shopify/liquid diff --git a/docs/source/filters/sha256.md b/docs/source/filters/sha256.md new file mode 100644 index 0000000000..659b863557 --- /dev/null +++ b/docs/source/filters/sha256.md @@ -0,0 +1,20 @@ +--- +title: sha256 +--- + +{% since %}vNEXT{% endsince %} + +Converts a string into an SHA-256 hash. The output is a lowercase hexadecimal string. + +Input + +```liquid +{%- assign secret_potion = 'Polyjuice' | sha256 -%} +My secret potion: {{ secret_potion }} +``` + +Output + +```text +My secret potion: 44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0 +``` diff --git a/rollup.config.mjs b/rollup.config.mjs index 5b353886b1..328505fdca 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -50,6 +50,11 @@ const browserBase64 = { delimiters: ['', ''], './base64-impl': '../build/base64-impl-browser' } +const browserCrypto = { + include: './src/filters/crypto.ts', + delimiters: ['', ''], + './crypto-impl': '../build/crypto-impl-browser' +} const browserStream = { include: './src/emitters/index.ts', delimiters: ['', ''], @@ -67,7 +72,7 @@ const nodeCjs = { format: 'cjs', banner }], - external: ['path', 'fs', 'stream'], + external: ['path', 'fs', 'stream', 'crypto'], plugins: [versionInjection, typescript(tsconfig('ES2020'))], treeshake, input @@ -79,7 +84,7 @@ const nodeEsm = { format: 'esm', banner }], - external: ['path', 'fs', 'stream'], + external: ['path', 'fs', 'stream', 'crypto'], plugins: [ versionInjection, replace(esmRequire), @@ -100,6 +105,7 @@ const browserEsm = { versionInjection, replace(browserFS), replace(browserBase64), + replace(browserCrypto), replace(browserStream), typescript(tsconfig('es6')) ], @@ -119,6 +125,7 @@ const browserUmd = { versionInjection, replace(browserFS), replace(browserBase64), + replace(browserCrypto), replace(browserStream), typescript(tsconfig('es5')) ], @@ -138,6 +145,7 @@ const browserMin = { versionInjection, replace(browserFS), replace(browserBase64), + replace(browserCrypto), replace(browserStream), typescript(tsconfig('es5')), uglify() diff --git a/src/build/crypto-impl-browser.spec.ts b/src/build/crypto-impl-browser.spec.ts new file mode 100644 index 0000000000..820838438c --- /dev/null +++ b/src/build/crypto-impl-browser.spec.ts @@ -0,0 +1,45 @@ +import { webcrypto } from 'crypto' +import * as cryptoImpl from './crypto-impl-browser' + +describe('crypto-impl/browser', function () { + beforeEach(function () { + Object.defineProperty(global, 'crypto', { + value: webcrypto, + writable: true, + configurable: true + }) + }) + + afterEach(function () { + delete (global as any).crypto + }) + + describe('#sha256()', function () { + it('should hash the Shopify reference example', async function () { + expect(await cryptoImpl.sha256('Polyjuice')) + .toBe('44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0') + }) + + it('should hash an empty string', async function () { + expect(await cryptoImpl.sha256('')) + .toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + }) + + it('should hash unicode characters', async function () { + expect(await cryptoImpl.sha256('你好世界')) + .toBe('beca6335b20ff57ccc47403ef4d9e0b8fccb4442b3151c2e7d50050673d43172') + }) + }) + + describe('#hmacSha256()', function () { + it('should hash the Shopify reference example', async function () { + expect(await cryptoImpl.hmacSha256('Polyjuice', 'Polina')) + .toBe('8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20') + }) + + it('should hash an empty message with a key', async function () { + expect(await cryptoImpl.hmacSha256('', 'key')) + .toBe('5d5d139563c95b5967b9bd9a8c9b233a9dedb45072794cd232dc1b74832607d0') + }) + }) +}) diff --git a/src/build/crypto-impl-browser.ts b/src/build/crypto-impl-browser.ts new file mode 100644 index 0000000000..20eb5c6490 --- /dev/null +++ b/src/build/crypto-impl-browser.ts @@ -0,0 +1,27 @@ +function bufferToHex (buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let hex = '' + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0') + } + return hex +} + +export async function sha256 (str: string): Promise { + const data = new TextEncoder().encode(str) + const digest = await crypto.subtle.digest('SHA-256', data) + return bufferToHex(digest) +} + +export async function hmacSha256 (str: string, key: string): Promise { + const encoder = new TextEncoder() + const cryptoKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(str)) + return bufferToHex(signature) +} diff --git a/src/filters/crypto-impl.ts b/src/filters/crypto-impl.ts new file mode 100644 index 0000000000..c2ed06f3b9 --- /dev/null +++ b/src/filters/crypto-impl.ts @@ -0,0 +1,9 @@ +import { createHash, createHmac } from 'crypto' + +export function sha256 (str: string): string { + return createHash('sha256').update(str, 'utf8').digest('hex') +} + +export function hmacSha256 (str: string, key: string): string { + return createHmac('sha256', key).update(str, 'utf8').digest('hex') +} diff --git a/src/filters/crypto.ts b/src/filters/crypto.ts new file mode 100644 index 0000000000..7210523bde --- /dev/null +++ b/src/filters/crypto.ts @@ -0,0 +1,22 @@ +/** + * Crypto related filters + * + * Implements sha256 and hmac_sha256 filters for Shopify compatibility + */ + +import { FilterImpl } from '../template' +import { stringify } from '../util' +import { sha256 as sha256Impl, hmacSha256 as hmacSha256Impl } from './crypto-impl' + +export function sha256 (this: FilterImpl, value: unknown): string | Promise { + const str = stringify(value) + this.context.memoryLimit.use(str.length) + return sha256Impl(str) +} + +export function hmac_sha256 (this: FilterImpl, value: unknown, key: unknown): string | Promise { + const str = stringify(value) + const keyStr = stringify(key) + this.context.memoryLimit.use(str.length + keyStr.length) + return hmacSha256Impl(str, keyStr) +} diff --git a/src/filters/index.ts b/src/filters/index.ts index ef245a1c27..d3b304b5fb 100644 --- a/src/filters/index.ts +++ b/src/filters/index.ts @@ -5,6 +5,7 @@ import * as arrayFilters from './array' import * as dateFilters from './date' import * as stringFilters from './string' import * as base64Filters from './base64' +import * as cryptoFilters from './crypto' import misc from './misc' import { FilterImplOptions } from '../template' @@ -16,5 +17,6 @@ export const filters: Record = { ...dateFilters, ...stringFilters, ...base64Filters, + ...cryptoFilters, ...misc } diff --git a/test/integration/filters/crypto.spec.ts b/test/integration/filters/crypto.spec.ts new file mode 100644 index 0000000000..be35aea54f --- /dev/null +++ b/test/integration/filters/crypto.spec.ts @@ -0,0 +1,70 @@ +import { test } from '../../stub/render' + +const SHA256_EMPTY = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + +describe('filters/crypto', function () { + describe('sha256', function () { + it('should hash the Shopify reference example', () => { + return test( + '{{ "Polyjuice" | sha256 }}', + '44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0' + ) + }) + + it('should hash an empty string', () => { + return test('{{ "" | sha256 }}', SHA256_EMPTY) + }) + + it('should treat undefined as empty string', () => { + return test('{{ foo | sha256 }}', SHA256_EMPTY) + }) + + it('should treat null as empty string', () => { + return test('{{ null | sha256 }}', SHA256_EMPTY) + }) + + it('should stringify numeric input', () => { + return test( + '{{ 123 | sha256 }}', + 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3' + ) + }) + + it('should stringify boolean input', () => { + return test( + '{{ true | sha256 }}', + 'b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b' + ) + }) + }) + + describe('hmac_sha256', function () { + it('should hash the Shopify reference example', () => { + return test( + "{{ 'Polyjuice' | hmac_sha256: 'Polina' }}", + '8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20' + ) + }) + + it('should accept a numeric key (stringified)', () => { + return test( + "{{ 'hello' | hmac_sha256: 42 }}", + '3bdadea6ed0e95ededc15dc4421ce7654c970156843dfd997be3fef5358168ca' + ) + }) + + it('should hash an empty message with an empty key', () => { + return test( + "{{ '' | hmac_sha256: '' }}", + 'b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad' + ) + }) + + it('should treat undefined input as empty string', () => { + return test( + "{{ foo | hmac_sha256: '' }}", + 'b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad' + ) + }) + }) +})