Skip to content

Commit 7bc56be

Browse files
authored
feat(utilities): add Node-less implementations of signature functions (#563)
Adds runtime-independent implementations of `createHmacSignature` and `createStorageContentSignature` functions. Those use native methods/objects instead of Node-specific constructs (`node:crypto`, `Buffer`). This allows us to omit polyfills for those modules in downstream projects, which e.g. shrinks the bundle sizes significantly See e.g. `apify-client` browser bundle size: |before|after| |---|---| |<img width="392" height="207" alt="image" src="https://github.com/user-attachments/assets/13eb7dc8-2a43-4374-9eaf-fcc81c64b82e" /> | <img width="368" height="196" alt="image" src="https://github.com/user-attachments/assets/ecfb32a4-1541-422c-bbce-136030931a14" />| Related to #537
1 parent 6b76a07 commit 7bc56be

File tree

4 files changed

+167
-4
lines changed

4 files changed

+167
-4
lines changed

packages/utilities/src/hmac.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ function encodeBase62(num: bigint) {
2525
* @param secretKey {string} Secret key used for signing signatures
2626
* @param message {string} Message to be signed
2727
* @returns string
28+
* @deprecated Use {@link createHmacSignatureAsync} instead, which uses Web Crypto API and
29+
* is available in both Node.js and browsers without the need for polyfills.
2830
*/
2931
export function createHmacSignature(secretKey: string, message: string): string {
3032
const signature = crypto.createHmac('sha256', secretKey)
@@ -34,3 +36,45 @@ export function createHmacSignature(secretKey: string, message: string): string
3436

3537
return encodeBase62(BigInt(`0x${signature}`));
3638
}
39+
40+
let webcrypto = globalThis.crypto?.subtle;
41+
42+
async function ensureCryptoSubtleExists() {
43+
// this might happen in Node.js versions < 19
44+
webcrypto ??= (await import('node:crypto')).webcrypto.subtle as typeof webcrypto;
45+
}
46+
47+
/**
48+
* Generates an HMAC signature and encodes it using Base62.
49+
* Base62 encoding reduces the signature length.
50+
*
51+
* @param secretKey {string} Secret key used for signing signatures
52+
* @param message {string} Message to be signed
53+
* @returns Promise<string>
54+
*/
55+
export async function createHmacSignatureAsync(secretKey: string, message: string): Promise<string> {
56+
await ensureCryptoSubtleExists();
57+
const encoder = new TextEncoder();
58+
59+
const key = await webcrypto.importKey(
60+
'raw',
61+
encoder.encode(secretKey),
62+
{ name: 'HMAC', hash: 'SHA-256' },
63+
false,
64+
['sign'],
65+
);
66+
67+
const signatureBuffer = await webcrypto.sign(
68+
'HMAC',
69+
key,
70+
encoder.encode(message),
71+
);
72+
73+
const signatureArray = new Uint8Array(signatureBuffer);
74+
const signatureHex = Array.from(signatureArray)
75+
.map((b) => b.toString(16).padStart(2, '0'))
76+
.join('')
77+
.substring(0, 30);
78+
79+
return encodeBase62(BigInt(`0x${signatureHex}`));
80+
}

packages/utilities/src/storages.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { createHmacSignature } from './hmac';
1+
import { createHmacSignature, createHmacSignatureAsync } from './hmac';
22

33
/**
44
* Creates a secure signature for a resource like a dataset or key-value store.
55
* This signature is used to generate a signed URL for authenticated access, which can be expiring or permanent.
66
* The signature is created using HMAC with the provided secret key and includes the resource ID, expiration time, and version.
77
*
8-
* Note: expirationMillis is optional. If not provided, the signature will not expire.
8+
* Note: expiresInMillis is optional. If not provided, the signature will not expire.
9+
*
10+
* @deprecated Use {@link createStorageContentSignatureAsync} instead, which uses Web Crypto API and
11+
* is available in both Node.js and browsers without the need for polyfills.
912
*/
1013
export function createStorageContentSignature({
1114
resourceId,
@@ -22,3 +25,35 @@ export function createStorageContentSignature({
2225
const hmac = createHmacSignature(urlSigningSecretKey, `${version}.${expiresAt}.${resourceId}`);
2326
return Buffer.from(`${version}.${expiresAt}.${hmac}`).toString('base64url');
2427
}
28+
29+
function typedArrayToBase64Url(typedArray: Uint8Array): string {
30+
let binary = '';
31+
for (let i = 0; i < typedArray.length; i++) {
32+
binary += String.fromCharCode(typedArray[i]);
33+
}
34+
const base64 = btoa(binary);
35+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
36+
}
37+
38+
/**
39+
* Creates a secure signature for a resource like a dataset or key-value store.
40+
* This signature is used to generate a signed URL for authenticated access, which can be expiring or permanent.
41+
* The signature is created using HMAC with the provided secret key and includes the resource ID, expiration time, and version.
42+
*
43+
* Note: expiresInMillis is optional. If not provided, the signature will not expire.
44+
*/
45+
export async function createStorageContentSignatureAsync({
46+
resourceId,
47+
urlSigningSecretKey,
48+
expiresInMillis,
49+
version = 0,
50+
}: {
51+
resourceId: string;
52+
urlSigningSecretKey: string;
53+
expiresInMillis?: number;
54+
version?: number;
55+
}): Promise<string> {
56+
const expiresAt = expiresInMillis ? new Date().getTime() + expiresInMillis : 0;
57+
const hmac = await createHmacSignatureAsync(urlSigningSecretKey, `${version}.${expiresAt}.${resourceId}`);
58+
return typedArrayToBase64Url(new TextEncoder().encode(`${version}.${expiresAt}.${hmac}`));
59+
}

test/hmac.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHmacSignature } from '@apify/utilities';
1+
import { createHmacSignature, createHmacSignatureAsync, cryptoRandomObjectId } from '@apify/utilities';
22

33
describe('createHmacSignature()', () => {
44
it('should create a valid HMAC signature', () => {
@@ -15,3 +15,31 @@ describe('createHmacSignature()', () => {
1515
}
1616
});
1717
});
18+
19+
describe('createHmacSignatureAsync()', () => {
20+
it('should create a valid HMAC signature', async () => {
21+
const secretKey = 'hmac-secret-key';
22+
const message = 'hmac-message-to-be-authenticated';
23+
await expect(createHmacSignatureAsync(secretKey, message)).resolves.toBe('pcVagAsudj8dFqdlg7mG');
24+
});
25+
26+
it('should create same HMAC signature, when secretKey and message are same', async () => {
27+
const secretKey = 'hmac-same-secret-key';
28+
const message = 'hmac-same-message-to-be-authenticated';
29+
for (let i = 0; i < 5; i++) {
30+
await expect(createHmacSignatureAsync(secretKey, message)).resolves.toBe('FYMcmTIm3idXqleF1Sw5');
31+
}
32+
});
33+
34+
it('should create same HMAC signature for same inputs', async () => {
35+
for (let i = 0; i < 1e3; i++) {
36+
const secretKey = cryptoRandomObjectId();
37+
const message = cryptoRandomObjectId();
38+
39+
const syncHmac = createHmacSignature(secretKey, message);
40+
const asyncHmac = await createHmacSignatureAsync(secretKey, message);
41+
42+
expect(syncHmac).toBe(asyncHmac);
43+
}
44+
});
45+
});

test/storages.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createStorageContentSignature } from '@apify/utilities';
1+
import { createStorageContentSignature, createStorageContentSignatureAsync, cryptoRandomObjectId } from '@apify/utilities';
22

33
describe('createStorageContentSignature()', () => {
44
it('should set expiresAt to 0 for a non-expiring signature', () => {
@@ -32,3 +32,59 @@ describe('createStorageContentSignature()', () => {
3232
expect(expiresAt).not.toBe('0');
3333
});
3434
});
35+
36+
describe('createStorageContentSignatureAsync()', () => {
37+
it('should set expiresAt to 0 for a non-expiring signature', async () => {
38+
const secretKey = 'hmac-secret-key';
39+
const message = 'resource-id';
40+
41+
const signature = await createStorageContentSignatureAsync({
42+
resourceId: message,
43+
urlSigningSecretKey: secretKey,
44+
});
45+
46+
const [version, expiresAt, hmac] = Buffer.from(signature, 'base64url').toString('utf8').split('.');
47+
expect(signature).toBe('MC4wLjNUd2ZFRTY1OXVmU05zbVM0N2xS');
48+
expect(version).toBe('0');
49+
expect(expiresAt).toBe('0');
50+
expect(hmac).toBe('3TwfEE659ufSNsmS47lR');
51+
});
52+
53+
it('should create a signature with a future expiration timestamp when expiresInMillis is provided', async () => {
54+
const secretKey = 'hmac-secret-key';
55+
const message = 'resource-id';
56+
57+
const signature = await createStorageContentSignatureAsync({
58+
resourceId: message,
59+
urlSigningSecretKey: secretKey,
60+
expiresInMillis: 10000,
61+
});
62+
63+
const [version, expiresAt] = Buffer.from(signature, 'base64url').toString('utf8').split('.');
64+
expect(version).toBe('0');
65+
expect(expiresAt).not.toBe('0');
66+
});
67+
68+
it('should create same storage signature for same inputs', async () => {
69+
for (let i = 0; i < 1e3; i++) {
70+
const secretKey = cryptoRandomObjectId();
71+
const resourceId = cryptoRandomObjectId();
72+
73+
jest.useFakeTimers().setSystemTime(Date.now());
74+
75+
const syncSignature = createStorageContentSignature({
76+
resourceId,
77+
urlSigningSecretKey: secretKey,
78+
expiresInMillis: 5000,
79+
});
80+
const asyncSignature = await createStorageContentSignatureAsync({
81+
resourceId,
82+
urlSigningSecretKey: secretKey,
83+
expiresInMillis: 5000,
84+
});
85+
86+
jest.useRealTimers();
87+
expect(syncSignature).toBe(asyncSignature);
88+
}
89+
});
90+
});

0 commit comments

Comments
 (0)