feat: add sha256 and hmac_sha256 filters for cryptographic operations (#889)

This commit is contained in:
Vladimir Filonov
2026-05-03 12:03:26 +08:00
committed by GitHub
parent 34877950bf
commit 1c816d4fc3
10 changed files with 226 additions and 2 deletions
+20
View File
@@ -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
```
+1
View File
@@ -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
+20
View File
@@ -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
```
+10 -2
View File
@@ -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()
+45
View File
@@ -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')
})
})
})
+27
View File
@@ -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<string> {
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<string> {
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)
}
+9
View File
@@ -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')
}
+22
View File
@@ -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<string> {
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<string> {
const str = stringify(value)
const keyStr = stringify(key)
this.context.memoryLimit.use(str.length + keyStr.length)
return hmacSha256Impl(str, keyStr)
}
+2
View File
@@ -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<string, FilterImplOptions> = {
...dateFilters,
...stringFilters,
...base64Filters,
...cryptoFilters,
...misc
}
+70
View File
@@ -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'
)
})
})
})