diff --git a/.changeset/olive-eggs-look.md b/.changeset/olive-eggs-look.md new file mode 100644 index 000000000..85e8fedf9 --- /dev/null +++ b/.changeset/olive-eggs-look.md @@ -0,0 +1,5 @@ +--- +"@shopify/shopify-api": minor +--- + +Added the ability to encrypt Session access tokens using AES-GCM with a 128-bit tag and a 12-byte random IV. diff --git a/packages/apps/shopify-api/docs/guides/session-storage.md b/packages/apps/shopify-api/docs/guides/session-storage.md index 138c01253..f275ac47d 100644 --- a/packages/apps/shopify-api/docs/guides/session-storage.md +++ b/packages/apps/shopify-api/docs/guides/session-storage.md @@ -1,22 +1,16 @@ # Storing sessions -As of v6 of the library, there are no `SessionStorage` implementations included and the responsibility for implementing session storage is now delegated to the application. +In order to be able to load user data without using cookies, your app will need to store its access tokens and other relevant data. -The previous implementations of `SessionStorage` are now available in their own packages, the source of which is available in the [respective directory](../../../session-storage#readme). +This package provides a `SessionStorage` interface that makes it easy to plug in a new storage strategy to your app. +You can use of one the [packages we provide](../../../session-storage/README.md), or implement your own following the instructions in this page. -| Package | Session storage object | Notes | -| :-----------------------------------------------: | :----------------------: | ---------------------------------------- | -| `@shopify/shopify-app-session-storage-memory` | MemorySessionStorage | | -| `@shopify/shopify-app-session-storage-mongodb` | MongoDBSessionStorage | | -| `@shopify/shopify-app-session-storage-mysql` | MySQLSessionStorage | | -| `@shopify/shopify-app-session-storage-postgresql` | PostgreSQLSessionStorage | | -| `@shopify/shopify-app-session-storage-redis` | RedisSessionStorage | | -| `@shopify/shopify-app-session-storage-sqlite` | SQLiteSessionStorage | | -| `@shopify/shopify-app-session-storage-dynamodb` | DynamoDBSessionStorage | | -| `@shopify/shopify-app-session-storage-kv` | KVSessionStorage | | -| `@shopify/shopify-app-session-storage` | SessionStorage | Abstract class used by the classes above | +In this page, you'll file: -## Basics +- [What data is in a `Session` object?](#what-data-is-in-a-session-object) +- [Save a session to storage](#save-a-session-to-storage) +- [Load a session from storage](#load-a-session-from-storage) +- [Encrypting data for storage](#encrypting-data-for-storage) ### What data is in a `Session` object? @@ -61,11 +55,9 @@ const sessionCopy = new Session(callbackResponse.session.toObject()); // sessionCopy is an identical copy of the callbackResponse.session instance ``` -Now that the app has a JavaScript object containing the data of a `Session`, it can convert the data into whatever means necessary to store it in the apps preferred storage mechanism. Various implementations of session storage can be found in the [`session-storages` folder](../../../session-storage#readme). +When converting the data for storage, the `Session` class also includes an instance method called `.toPropertyArray` that returns an array of key-value pairs constructed from the result of `toObject`. `toPropertyArray` has an optional parameter `returnUserData`, defaulted to false, when set to true it will return the associated user data as part of the property array object. -The `Session` class also includes an instance method called `.toPropertyArray` that returns an array of key-value pairs, e.g., - -`toPropertyArray` has an optional parameter `returnUserData`, defaulted to false, when set to true it will return the associated user data as part of the property array object. +With that object containing the data of a `Session`, the app can convert the data into whatever it needs to store using its preferred mechanism. Various implementations of session storage can be found in the [`session-storages` folder](../../../session-storage#readme). ```ts const {session, headers} = shopify.auth.callback({ @@ -294,4 +286,37 @@ const session = Session.fromPropertyArray(sessionProperties); > > The existing [SQL-based implementations](../../../session-storage#readme), i.e., MySQL, PostgreSQL and SQLite, convert it from seconds from storage. The remaining implementations do not change the retrieved `expires` property. +### Encrypting data for storage + +If you want to encrypt your sessions before storing them, the `Session` class provides two methods: `fromEncryptedPropertyArray` and `toEncryptedPropertyArray`. + +These behave the same as their non-encrypted counterparts, but are `async` and take in a `CryptoKey` object. +If a session is currently not encrypted in storage, these methods will still load it normally, and will encrypt them when saving them. + +`fromEncryptedPropertyArray` will return ciphers for the encrypted fields, prefixed with `encrypted#`. +That way, storage providers can progressively update sessions as they're loaded and saved, or run a migration script that simply loads and saves every session. + +By default, only the access token is encrypted, but you can pass in any fields (including custom ones not from the `Session` class) you want to encrypt. +Note that some fields are not allowed for encryption: `id`, `expires`, and any of the boolean fields. + +You can use the static `Session.DEFAULT_ENCRYPTED_PROPERTIES` to encrypt the default properties, in addition to your own. +That enables apps to continue using the default behaviour while customizing it. + +```ts +// Get encrypted data array +const sessionProperties = await session.toEncryptedPropertyArray({ + cryptoKey, + // Use the default encrypted columns + propertiesToEncrypt: [...Session.DEFAULT_ENCRYPTED_PROPERTIES, 'customField'], +}); + +// Store and load data +// ... + +// Create session from encrypted data +const newSession = Session.fromEncryptedPropertyArray(sessionProperties, { + cryptoKey, +}); +``` + [Back to guide index](../../README.md#guides) diff --git a/packages/apps/shopify-api/lib/session/__tests__/session.test.ts b/packages/apps/shopify-api/lib/session/__tests__/session.test.ts index 5634f8881..fdd4c28cb 100644 --- a/packages/apps/shopify-api/lib/session/__tests__/session.test.ts +++ b/packages/apps/shopify-api/lib/session/__tests__/session.test.ts @@ -2,6 +2,7 @@ import {Session} from '../session'; import {testConfig} from '../../__tests__/test-config'; import {shopifyApi} from '../..'; import {AuthScopes} from '../../auth'; +import {getCryptoLib} from '../../../runtime'; describe('session', () => { it('can create a session from another session', () => { @@ -636,3 +637,279 @@ describe('toPropertyArray and fromPropertyArray', () => { }); }); }); + +describe('toEncryptedPropertyArray and fromEncryptedPropertyArray', () => { + let cryptoKey: CryptoKey; + + beforeEach(async () => { + const cryptoLib = getCryptoLib(); + + cryptoKey = await cryptoLib.subtle.generateKey( + {name: 'AES-GCM', length: 256}, + true, + ['encrypt', 'decrypt'], + ); + }); + + testSessions.forEach((test) => { + const onlineOrOffline = test.session.isOnline ? 'online' : 'offline'; + const userData = test.returnUserData ? 'with' : 'without'; + + it(`returns a property array of an ${onlineOrOffline} session ${userData} user data`, async () => { + // GIVEN + const getPropIndex = (object: any, prop: string, check = true) => { + const index = object.findIndex((property: any) => property[0] === prop); + + if (check) expect(index).toBeGreaterThan(-1); + + return index; + }; + + const session = new Session(test.session); + const testProps = [...test.propertyArray]; + + // WHEN + const actualProps = await session.toEncryptedPropertyArray({ + cryptoKey, + returnUserData: test.returnUserData, + }); + + // THEN + + // The token is encrypted, so the values will be different + const tokenIndex = getPropIndex(testProps, 'accessToken', false); + const actualTokenIndex = getPropIndex(actualProps, 'accessToken', false); + + if (actualTokenIndex > -1 && tokenIndex > -1) { + expect( + actualProps[actualTokenIndex][1].toString().startsWith('encrypted#'), + ).toBeTruthy(); + + actualProps.splice(actualTokenIndex, 1); + testProps.splice(tokenIndex, 1); + } + + expect(actualProps).toStrictEqual(testProps); + }); + + it(`recreates a Session from a property array of an ${onlineOrOffline} session ${userData} user data`, async () => { + // GIVEN + const session = new Session(test.session); + + // WHEN + const actualSession = await Session.fromEncryptedPropertyArray( + await session.toEncryptedPropertyArray({ + cryptoKey, + returnUserData: test.returnUserData, + }), + {cryptoKey, returnUserData: test.returnUserData}, + ); + + // THEN + expect(actualSession.id).toStrictEqual(session.id); + expect(actualSession.shop).toStrictEqual(session.shop); + expect(actualSession.state).toStrictEqual(session.state); + expect(actualSession.isOnline).toStrictEqual(session.isOnline); + expect(actualSession.scope).toStrictEqual(session.scope); + expect(actualSession.accessToken).toStrictEqual(session.accessToken); + expect(actualSession.expires).toStrictEqual(session.expires); + + const user = session.onlineAccessInfo?.associated_user; + const actualUser = actualSession.onlineAccessInfo?.associated_user; + expect(actualUser?.id).toStrictEqual(user?.id); + + if (test.returnUserData) { + if (user && actualUser) { + expect(actualUser).toMatchObject(user); + } else { + expect(actualUser).toBeUndefined(); + expect(user).toBeUndefined(); + } + } else { + expect(actualUser?.first_name).toBeUndefined(); + expect(actualUser?.last_name).toBeUndefined(); + expect(actualUser?.email).toBeUndefined(); + expect(actualUser?.locale).toBeUndefined(); + expect(actualUser?.email_verified).toBeUndefined(); + expect(actualUser?.account_owner).toBeUndefined(); + expect(actualUser?.collaborator).toBeUndefined(); + } + }); + + const describe = test.session.isOnline ? 'Does' : 'Does not'; + const isOnline = test.session.isOnline ? 'online' : 'offline'; + + it(`${describe} have online access info when the token is ${isOnline}`, async () => { + // GIVEN + const session = new Session(test.session); + + // WHEN + const actualSession = await Session.fromEncryptedPropertyArray( + await session.toEncryptedPropertyArray({ + cryptoKey, + returnUserData: test.returnUserData, + }), + {cryptoKey, returnUserData: test.returnUserData}, + ); + + // THEN + if (test.session.isOnline) { + expect(actualSession.onlineAccessInfo).toBeDefined(); + } else { + expect(actualSession.onlineAccessInfo).toBeUndefined(); + } + }); + }); + + it('fails to decrypt an invalid token', async () => { + // GIVEN + const session = new Session({ + id: 'offline_session_id', + shop: 'offline-session-shop', + state: 'offline-session-state', + isOnline: false, + scope: 'offline-session-scope', + accessToken: 'offline-session-token', + expires: expiresDate, + }); + + const props = await session.toEncryptedPropertyArray({ + cryptoKey, + returnUserData: false, + }); + + // WHEN + const tamperedProps = props.map((derp) => { + return [ + derp[0], + derp[0] === 'accessToken' ? 'encrypted#invalid token' : derp[1], + ] as [string, string | number | boolean]; + }); + + // THEN + await expect(async () => + Session.fromEncryptedPropertyArray(tamperedProps, { + cryptoKey, + returnUserData: false, + }), + ).rejects.toThrow('The provided data is too small.'); + }); + + describe('encrypting multiple fields', () => { + let session: Session; + + beforeEach(() => { + session = new Session({ + id: 'offline_session_id', + shop: 'example.myshopify.io', + state: 'offline-session-state', + isOnline: true, + scope: 'test_scope', + accessToken: 'offline-session-token', + expires: expiresDate, + onlineAccessInfo: { + expires_in: 1, + associated_user_scope: 'user_scope', + associated_user: { + id: 1, + first_name: 'first-name', + last_name: 'last-name', + email: 'email', + locale: 'locale', + email_verified: true, + account_owner: true, + collaborator: false, + }, + }, + }); + }); + + it('can encrypt and decrypt all fields', async () => { + // GIVEN + const encryptFields = [ + 'shop', + 'state', + 'scope', + 'accessToken', + 'userId', + 'firstName', + 'lastName', + 'email', + 'locale', + ]; + + // WHEN + const encryptedProps = await session.toEncryptedPropertyArray({ + cryptoKey, + propertiesToEncrypt: encryptFields, + returnUserData: true, + }); + const newSession = await Session.fromEncryptedPropertyArray( + encryptedProps, + {cryptoKey, returnUserData: true}, + ); + + // THEN + expect(encryptedProps).toMatchObject([ + ['id', 'offline_session_id'], + ['shop', expect.stringMatching(/^encrypted#/)], + ['state', expect.stringMatching(/^encrypted#/)], + ['isOnline', true], + ['scope', expect.stringMatching(/^encrypted#/)], + ['accessToken', expect.stringMatching(/^encrypted#/)], + ['expires', expect.any(Number)], + ['userId', expect.stringMatching(/^encrypted#/)], + ['firstName', expect.stringMatching(/^encrypted#/)], + ['lastName', expect.stringMatching(/^encrypted#/)], + ['email', expect.stringMatching(/^encrypted#/)], + ['locale', expect.stringMatching(/^encrypted#/)], + ['emailVerified', true], + ['accountOwner', true], + ['collaborator', false], + ]); + expect(newSession.equals(session)).toBeTruthy(); + }); + + it('can encrypt and decrypt custom fields', async () => { + // GIVEN + const sessionWithCustomFields = new Session({ + ...session.toObject(), + customField: 'custom', + }); + + // WHEN + const encryptedProps = + await sessionWithCustomFields.toEncryptedPropertyArray({ + cryptoKey, + propertiesToEncrypt: ['customField'], + returnUserData: true, + }); + const newSession = await Session.fromEncryptedPropertyArray( + encryptedProps, + {cryptoKey, returnUserData: true}, + ); + + // THEN + const index = encryptedProps.findIndex(([key]) => key === 'customField'); + expect(index).toBeGreaterThan(-1); + expect(encryptedProps[index][1]).toMatch(/^encrypted#/); + expect((newSession as any).customField).toEqual( + (sessionWithCustomFields as any).customField, + ); + }); + + it.each(['id', 'expires', 'emailVerified', 'accountOwner', 'collaborator'])( + "can't encrypt '%s' field", + async (field) => { + // WHEN + await expect( + session.toEncryptedPropertyArray({ + cryptoKey, + propertiesToEncrypt: [field], + returnUserData: true, + }), + ).rejects.toThrow(`Can't encrypt fields: [${field}]`); + }, + ); + }); +}); diff --git a/packages/apps/shopify-api/lib/session/session.ts b/packages/apps/shopify-api/lib/session/session.ts index 4f0f8c3a1..a8ee35694 100644 --- a/packages/apps/shopify-api/lib/session/session.ts +++ b/packages/apps/shopify-api/lib/session/session.ts @@ -1,27 +1,49 @@ /* eslint-disable no-fallthrough */ + import {InvalidSession} from '../error'; import {OnlineAccessInfo} from '../auth/oauth/types'; import {AuthScopes} from '../auth/scopes'; +import {decryptValue, encryptValue, CIPHER_PREFIX} from '../../runtime/crypto'; import {SessionParams} from './types'; -const propertiesToSave = [ - 'id', - 'shop', - 'state', - 'isOnline', - 'scope', - 'accessToken', - 'expires', - 'onlineAccessInfo', -]; +type SessionParamsArray = [string, string | number | boolean][]; + +interface ToEncryptedPropertyArrayOptions { + cryptoKey: CryptoKey; + returnUserData?: boolean; + propertiesToEncrypt?: string[]; +} + +interface FromEncryptedPropertyArrayOptions { + cryptoKey: CryptoKey; + returnUserData?: boolean; +} + +interface FlattenPropertiesOptions { + returnUserData: boolean; + propertiesToSave?: string[]; +} /** * Stores App information from logged in merchants so they can make authenticated requests to the Admin API. */ export class Session { + public static DEFAULT_ENCRYPTED_PROPERTIES = ['accessToken']; + + private static DEFAULT_PROPERTIES_TO_SAVE = [ + 'id', + 'shop', + 'state', + 'isOnline', + 'scope', + 'accessToken', + 'expires', + 'onlineAccessInfo', + ]; + public static fromPropertyArray( - entries: [string, string | number | boolean][], + entries: SessionParamsArray, returnUserData = false, ): Session { if (!Array.isArray(entries)) { @@ -53,7 +75,7 @@ export class Session { case 'emailverified': return ['emailVerified', value]; default: - return [key.toLowerCase(), value]; + return [key, value]; } }), ); @@ -134,6 +156,25 @@ export class Session { return session; } + public static async fromEncryptedPropertyArray( + entries: SessionParamsArray, + {cryptoKey, returnUserData = false}: FromEncryptedPropertyArrayOptions, + ) { + const decryptedEntries: SessionParamsArray = []; + for (const [key, value] of entries) { + if (value.toString().startsWith(CIPHER_PREFIX)) { + decryptedEntries.push([ + key, + await decryptValue(value as string, cryptoKey), + ]); + } else { + decryptedEntries.push([key, value]); + } + } + + return this.fromPropertyArray(decryptedEntries, returnUserData); + } + /** * The unique identifier for the session. */ @@ -208,29 +249,10 @@ export class Session { } /** - * Converts an object with data into a Session. + * Converts a Session into an object with its data, that can be used to construct another Session. */ public toObject(): SessionParams { - const object: SessionParams = { - id: this.id, - shop: this.shop, - state: this.state, - isOnline: this.isOnline, - }; - - if (this.scope) { - object.scope = this.scope; - } - if (this.expires) { - object.expires = this.expires; - } - if (this.accessToken) { - object.accessToken = this.accessToken; - } - if (this.onlineAccessInfo) { - object.onlineAccessInfo = this.onlineAccessInfo; - } - return object; + return {...this}; } /** @@ -259,11 +281,65 @@ export class Session { /** * Converts the session into an array of key-value pairs. */ - public toPropertyArray( + public toPropertyArray(returnUserData = false): SessionParamsArray { + return this.flattenProperties(this.toObject(), {returnUserData}); + } + + /** + * Converts the session into an array of key-value pairs, encrypting sensitive data. + * + * The encrypted string will contain both the IV and the encrypted value. + */ + public async toEncryptedPropertyArray({ + cryptoKey, + propertiesToEncrypt = Session.DEFAULT_ENCRYPTED_PROPERTIES, returnUserData = false, - ): [string, string | number | boolean][] { + }: ToEncryptedPropertyArrayOptions): Promise { + const disallowedFields = propertiesToEncrypt.filter((field) => + [ + 'id', + 'expires', + 'emailVerified', + 'accountOwner', + 'collaborator', + ].includes(field), + ); + if (disallowedFields.length > 0) { + throw new InvalidSession( + `Can't encrypt fields: [${disallowedFields.join(', ')}]`, + ); + } + + const allPropertiesToSave = [ + ...new Set([ + ...propertiesToEncrypt, + ...Session.DEFAULT_PROPERTIES_TO_SAVE, + ]), + ]; + const properties = this.flattenProperties(this.toObject(), { + returnUserData, + propertiesToSave: allPropertiesToSave, + }); + + return Promise.all( + properties.map(async ([key, value]) => { + if (propertiesToEncrypt.includes(key) && value) { + return [key, await encryptValue(value.toString(), cryptoKey)]; + } + return [key, value]; + }), + ); + } + + private flattenProperties( + params: SessionParams, + { + returnUserData = false, + propertiesToSave = Session.DEFAULT_PROPERTIES_TO_SAVE, + }: FlattenPropertiesOptions, + ): SessionParamsArray { return ( - Object.entries(this) + Object.entries(params) .filter( ([key, value]) => propertiesToSave.includes(key) && @@ -271,7 +347,7 @@ export class Session { value !== null, ) // Prepare values for db storage - .flatMap(([key, value]): [string, string | number | boolean][] => { + .flatMap(([key, value]): SessionParamsArray => { switch (key) { case 'expires': return [[key, value ? value.getTime() : undefined]]; diff --git a/packages/apps/shopify-api/runtime/__tests__/all.test.ts b/packages/apps/shopify-api/runtime/__tests__/all.test.ts index 3dbed63e2..3277a8210 100644 --- a/packages/apps/shopify-api/runtime/__tests__/all.test.ts +++ b/packages/apps/shopify-api/runtime/__tests__/all.test.ts @@ -1,3 +1,4 @@ +import '../crypto/__tests__/encrypt.test'; import '../crypto/__tests__/hmac.test'; import '../http/__tests__/http.test'; import '../platform/__tests__/platform.test'; diff --git a/packages/apps/shopify-api/runtime/crypto/__tests__/encrypt.test.ts b/packages/apps/shopify-api/runtime/crypto/__tests__/encrypt.test.ts new file mode 100644 index 000000000..95e2bc5ba --- /dev/null +++ b/packages/apps/shopify-api/runtime/crypto/__tests__/encrypt.test.ts @@ -0,0 +1,54 @@ +import {decrypt, decryptValue, encrypt, encryptValue} from '../encrypt'; +import {getCryptoLib} from '../utils'; + +let key: CryptoKey; + +beforeAll(async () => { + const cryptoLib = getCryptoLib(); + key = await cryptoLib.subtle.generateKey( + {name: 'AES-GCM', length: 256}, + true, + ['encrypt', 'decrypt'], + ); +}); + +describe('encrypt / decrypt', () => { + it('can encrypt and decrypt a string with a random key and IV', async () => { + // GIVEN + const cryptoLib = getCryptoLib(); + const iv = cryptoLib.getRandomValues(new Uint8Array(12)); + const value = 'Test encrypted value'; + + // WHEN + const encryptedValue = await encrypt(value, {key, iv}); + const result = await decrypt(encryptedValue, {key, iv}); + + // THEN + expect(encryptedValue).not.toEqual(value); + expect(result).toEqual(value); + }); +}); + +describe('encryptValue / decryptValue', () => { + it('can encrypt and decrypt a value with a random key and IV', async () => { + // GIVEN + const value = 'Test encrypted value'; + + // WHEN + const encryptedValue = await encryptValue(value, key); + const result = await decryptValue(encryptedValue, key); + + // THEN + expect(encryptedValue.startsWith('encrypted#')).toBe(true); + expect(encryptedValue).not.toEqual(value); + expect(result).toEqual(value); + }); + + it('fails to decrypt a tampered value', async () => { + // GIVEN + const tamperedValue = 'encrypted#not_a_real_cipher'; + + // THEN + await expect(decryptValue(tamperedValue, key)).rejects.toThrow(); + }); +}); diff --git a/packages/apps/shopify-api/runtime/crypto/encrypt.ts b/packages/apps/shopify-api/runtime/crypto/encrypt.ts new file mode 100644 index 000000000..5755c8da9 --- /dev/null +++ b/packages/apps/shopify-api/runtime/crypto/encrypt.ts @@ -0,0 +1,57 @@ +import type {EncryptionOptions} from './types'; +import {asBase64, fromBase64, getCryptoLib} from './utils'; + +export const CIPHER_PREFIX = 'encrypted#'; + +export function generateIV(): Uint8Array { + return getCryptoLib().getRandomValues(new Uint8Array(12)); +} + +export async function encrypt( + value: string, + {key, iv}: EncryptionOptions, +): Promise { + const cryptoLib = getCryptoLib(); + + const encrypted = await cryptoLib.subtle.encrypt( + {name: 'AES-GCM', iv, tagLength: 128}, + key, + new TextEncoder().encode(value), + ); + + return asBase64(encrypted); +} + +export async function decrypt( + encryptedValue: string, + {key, iv}: EncryptionOptions, +): Promise { + const cryptoLib = getCryptoLib(); + + const decrypted = await cryptoLib.subtle.decrypt( + {name: 'AES-GCM', iv}, + key, + fromBase64(encryptedValue), + ); + + return new TextDecoder().decode(decrypted); +} + +export async function encryptValue(value: string, key: CryptoKey) { + const iv = generateIV(); + const cipher = await encrypt(value, {key, iv}); + + return `${CIPHER_PREFIX}${asBase64(iv)}${cipher}`; +} + +export async function decryptValue(value: string, key: CryptoKey) { + if (!value.startsWith(CIPHER_PREFIX)) { + return value; + } + + const keyString = value.slice(CIPHER_PREFIX.length); + const iv = new Uint8Array(fromBase64(keyString.slice(0, 16))); + const cipher = keyString.slice(16); + + return decrypt(cipher, {key, iv}); +} diff --git a/packages/apps/shopify-api/runtime/crypto/index.ts b/packages/apps/shopify-api/runtime/crypto/index.ts index c6c82f5fb..b6156baf4 100644 --- a/packages/apps/shopify-api/runtime/crypto/index.ts +++ b/packages/apps/shopify-api/runtime/crypto/index.ts @@ -1,3 +1,11 @@ export * from './types'; export * from './crypto'; +export { + CIPHER_PREFIX, + encrypt as encryptString, + encryptValue, + decrypt as decryptString, + decryptValue, + generateIV, +} from './encrypt'; export * from './utils'; diff --git a/packages/apps/shopify-api/runtime/crypto/types.ts b/packages/apps/shopify-api/runtime/crypto/types.ts index 3f913d482..34de4c499 100644 --- a/packages/apps/shopify-api/runtime/crypto/types.ts +++ b/packages/apps/shopify-api/runtime/crypto/types.ts @@ -2,3 +2,8 @@ export enum HashFormat { Base64 = 'base64', Hex = 'hex', } + +export interface EncryptionOptions { + key: CryptoKey; + iv: Uint8Array; +} diff --git a/packages/apps/shopify-api/runtime/crypto/utils.ts b/packages/apps/shopify-api/runtime/crypto/utils.ts index a01781b44..db8dab5c3 100644 --- a/packages/apps/shopify-api/runtime/crypto/utils.ts +++ b/packages/apps/shopify-api/runtime/crypto/utils.ts @@ -8,10 +8,7 @@ export async function createSHA256HMAC( payload: string, returnFormat: HashFormat = HashFormat.Base64, ): Promise { - const cryptoLib = - typeof (crypto as any)?.webcrypto === 'undefined' - ? crypto - : (crypto as any).webcrypto; + const cryptoLib = getCryptoLib(); const enc = new TextEncoder(); const key = await cryptoLib.subtle.importKey( @@ -43,6 +40,11 @@ export function asHex(buffer: ArrayBuffer): string { const LookupTable = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; +const ReverseLookupTable = new Uint8Array(256); +for (let i = 0; i < LookupTable.length; i++) { + ReverseLookupTable[LookupTable.charCodeAt(i)] = i; +} + export function asBase64(buffer: ArrayBuffer): string { let output = ''; @@ -73,6 +75,40 @@ export function asBase64(buffer: ArrayBuffer): string { return output; } +export function fromBase64(base64: string): ArrayBuffer { + let bufferLength = base64.length * 0.75; + const len = base64.length; + let i; + let part = 0; + let encoded1; + let encoded2; + let encoded3; + let encoded4; + + if (base64[base64.length - 1] === '=') { + bufferLength--; + if (base64[base64.length - 2] === '=') { + bufferLength--; + } + } + + const arraybuffer = new ArrayBuffer(bufferLength); + const bytes = new Uint8Array(arraybuffer); + + for (i = 0; i < len; i += 4) { + encoded1 = ReverseLookupTable[base64.charCodeAt(i)]; + encoded2 = ReverseLookupTable[base64.charCodeAt(i + 1)]; + encoded3 = ReverseLookupTable[base64.charCodeAt(i + 2)]; + encoded4 = ReverseLookupTable[base64.charCodeAt(i + 3)]; + + bytes[part++] = (encoded1 << 2) | (encoded2 >> 4); + bytes[part++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); + bytes[part++] = ((encoded3 & 3) << 6) | (encoded4 & 63); + } + + return arraybuffer; +} + export function hashString(str: string, returnFormat: HashFormat): string { const buffer = new TextEncoder().encode(str); @@ -85,3 +121,9 @@ export function hashString(str: string, returnFormat: HashFormat): string { throw new ShopifyError(`Unrecognized hash format '${returnFormat}'`); } } + +export function getCryptoLib(): Crypto { + return typeof (crypto as any)?.webcrypto === 'undefined' + ? crypto + : (crypto as any).webcrypto; +}