diff --git a/.changeset/friendly-files-train.md b/.changeset/friendly-files-train.md new file mode 100644 index 0000000..4c05e1b --- /dev/null +++ b/.changeset/friendly-files-train.md @@ -0,0 +1,5 @@ +--- +"@sigmacomputing/node-embed-sdk": minor +--- + +Add OAuth token encryption utils for JWT embeds diff --git a/packages/node-embed-sdk/.eslintrc.js b/packages/node-embed-sdk/.eslintrc.js new file mode 100644 index 0000000..8e1c45f --- /dev/null +++ b/packages/node-embed-sdk/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@sigmacomputing/eslint-config/react-internal.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.lint.json", + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/node-embed-sdk/README.md b/packages/node-embed-sdk/README.md new file mode 100644 index 0000000..38f258f --- /dev/null +++ b/packages/node-embed-sdk/README.md @@ -0,0 +1,47 @@ +# Sigma Node.js Embed SDK + +This package provides Node.js utilities for working with Sigma Computing's Embed API. + +## Getting Started + +To use the node-embed-sdk in your project, you can install it using your node package manager. + +**Using npm:** + +```code +npm install @sigmacomputing/node-embed-sdk +``` + +**yarn:** + +```code +yarn add @sigmacomputing/node-embed-sdk +``` + +**pnpm:** + +```code +pnpm add @sigmacomputing/node-embed-sdk +``` + +## Features + +### Token Encryption and Decryption + +The SDK provides utilities for encrypting and decrypting OAuth tokens using AES-256-GCM encryption: + +```typescript +import { encrypt, decrypt } from '@sigmacomputing/node-embed-sdk'; + +// Encrypt an OAuth token +const encryptedToken = encrypt( + 'your-embed-secret', + 'your-oauth-token' +); + +// Decrypt an encrypted token +const decryptedToken = decrypt( + 'your-embed-secret', + encryptedToken +); +``` diff --git a/packages/node-embed-sdk/package.json b/packages/node-embed-sdk/package.json new file mode 100644 index 0000000..77e77a8 --- /dev/null +++ b/packages/node-embed-sdk/package.json @@ -0,0 +1,54 @@ +{ + "name": "@sigmacomputing/node-embed-sdk", + "author": "sigmacomputing", + "version": "0.1.0", + "description": "Node.js SDK for Sigma Computing with encryption/decryption utilities", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/sigmacomputing/embed-sdk.git", + "directory": "packages/node-embed-sdk" + }, + "homepage": "https://sigmacomputing.com", + "bugs": { + "url": "https://github.com/sigmacomputing/embed-sdk/issues" + }, + "keywords": [ + "embed", + "sdk", + "sigma", + "node" + ], + "scripts": { + "prepublish": "turbo run build", + "build": "tsup", + "lint": "eslint . --ext .ts", + "watch": "tsup --watch", + "typecheck": "tsc --noEmit", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + "import": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "require": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "devDependencies": { + "@sigmacomputing/eslint-config": "workspace:*", + "@sigmacomputing/typescript-config": "workspace:*", + "@types/node": "^20.17.16" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/node-embed-sdk/src/encryption.ts b/packages/node-embed-sdk/src/encryption.ts new file mode 100644 index 0000000..2cf0128 --- /dev/null +++ b/packages/node-embed-sdk/src/encryption.ts @@ -0,0 +1,461 @@ +import crypto from 'node:crypto'; + +/* + * Configuration Constants + */ + +/** + * Constants for PBKDF2-HMAC-SHA256 key derivation. + * + * We are using PBKDF2 with SHA-256, 600000 iterations, and a 128-bit salt in accordance with + * {@link https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf NIST} + * and + * {@link https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 OWASP} + * recommendations. + * + * The key length is 256 bits since we are using AES-256-GCM. + */ +const PBKDF2_HMAC_SHA256_KEY_DERIVATION = { + DIGEST: 'sha256', + ITERATIONS: 600_000, + KEY_LENGTH_BYTES: 32, // 256 bits + SALT_LENGTH_BYTES: 16, // 128 bits +} as const; + +/** + * Constants for AES-256-GCM encryption. + * + * We are using a 96-bit IV and a 128-bit tag in accordance with + * {@link https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf NIST} + * recommendations. + */ +const AES_256_GCM_ENCRYPTION = { + ALGORITHM: 'aes-256-gcm', + IV_LENGTH_BYTES: 12, // 96 bits + TAG_LENGTH_BYTES: 16, // 128 bits +} as const; + +/* + * Branded Types for Cryptographic Buffers + */ + +interface PassphraseBrand { + readonly Passphrase: unique symbol; +} +type Passphrase_t = Buffer & PassphraseBrand; + +interface SaltBrand { + readonly Salt: unique symbol; +} +type Salt_t = Buffer & SaltBrand; + +interface TagBrand { + readonly Tag: unique symbol; +} +type Tag_t = Buffer & TagBrand; + +interface IVBrand { + readonly IV: unique symbol; +} +type IV_t = Buffer & IVBrand; + +interface CiphertextBrand { + readonly Ciphertext: unique symbol; +} +type Ciphertext_t = Buffer & CiphertextBrand; + +interface PlaintextBrand { + readonly Plaintext: unique symbol; +} +type Plaintext_t = Buffer & PlaintextBrand; + +interface SymmetricKeyBrand { + readonly SymmetricKey: unique symbol; +} +type SymmetricKey_t = Buffer & SymmetricKeyBrand; + +/* + * Interface Definitions for Cryptographic Operations + */ + +interface KeyDerivationOutput_t { + key: SymmetricKey_t; +} + +interface KeyEncryptionOutput_t { + tag: Tag_t; + iv: IV_t; + ciphertext: Ciphertext_t; +} + +interface KeyDecryptionOutput_t { + plaintext: Plaintext_t; +} + +type PassphraseEncryptionOutput_t = KeyEncryptionOutput_t & { + salt: Salt_t; +}; + +type PassphraseDecryptionOutput_t = KeyDecryptionOutput_t; + +interface EncodedPassphraseEncryptionOutputBrand { + readonly EncodedPassphraseEncryptionOutput: unique symbol; +} + +type EncodedPassphraseEncryptionOutput_t = string & + EncodedPassphraseEncryptionOutputBrand; + +/* + * Type Validation Functions + */ + +function isPassphrase(value: unknown): value is Passphrase_t { + if (!Buffer.isBuffer(value)) return false; + // The passphrase can be any length, so we don't have anything else to check. + return true; +} + +function isSalt(value: unknown): value is Salt_t { + if (!Buffer.isBuffer(value)) return false; + if (value.length !== PBKDF2_HMAC_SHA256_KEY_DERIVATION.SALT_LENGTH_BYTES) + return false; + return true; +} + +function isTag(value: unknown): value is Tag_t { + if (!Buffer.isBuffer(value)) return false; + if (value.length !== AES_256_GCM_ENCRYPTION.TAG_LENGTH_BYTES) return false; + return true; +} + +function isIV(value: unknown): value is IV_t { + if (!Buffer.isBuffer(value)) return false; + if (value.length !== AES_256_GCM_ENCRYPTION.IV_LENGTH_BYTES) return false; + return true; +} + +function isCiphertext(value: unknown): value is Ciphertext_t { + if (!Buffer.isBuffer(value)) return false; + // The ciphertext can be any length, so we don't have anything else to check. + return true; +} + +function isPlaintext(value: unknown): value is Plaintext_t { + if (!Buffer.isBuffer(value)) return false; + // The plaintext can be any length, so we don't have anything else to check. + return true; +} + +function isSymmetricKey(value: unknown): value is SymmetricKey_t { + if (!Buffer.isBuffer(value)) return false; + if (value.length !== PBKDF2_HMAC_SHA256_KEY_DERIVATION.KEY_LENGTH_BYTES) + return false; + return true; +} + +function isPassphraseEncryptionOutput( + value: unknown, +): value is PassphraseEncryptionOutput_t { + // The input should be a non-null object + if (!(value && typeof value === 'object')) return false; + // The object should have these properties + if (!('salt' in value)) return false; + if (!('iv' in value)) return false; + if (!('tag' in value)) return false; + if (!('ciphertext' in value)) return false; + // The properties should be the correct type + if (!isSalt(value.salt)) return false; + if (!isIV(value.iv)) return false; + if (!isTag(value.tag)) return false; + if (!isCiphertext(value.ciphertext)) return false; + return true; +} + +function isEncodedPassphraseEncryptionOutput( + value: unknown, +): value is EncodedPassphraseEncryptionOutput_t { + if (typeof value !== 'string') return false; + const parts = value.split('.'); + if (parts.length !== 4) return false; + const [salt, iv, tag, ciphertext] = parts; + if (!isSalt(Buffer.from(salt, 'base64'))) return false; + if (!isIV(Buffer.from(iv, 'base64'))) return false; + if (!isTag(Buffer.from(tag, 'base64'))) return false; + if (!isCiphertext(Buffer.from(ciphertext, 'base64'))) return false; + return true; +} + +/* + * Type Conversion Functions + */ + +function asPassphrase(value: unknown): Passphrase_t { + if (!isPassphrase(value)) { + throw new Error('Invalid passphrase.'); + } + return value; +} + +function asSalt(value: unknown): Salt_t { + if (!isSalt(value)) { + throw new Error('Invalid salt.'); + } + return value; +} + +function asTag(value: unknown): Tag_t { + if (!isTag(value)) { + throw new Error('Invalid tag.'); + } + return value; +} + +function asIV(value: unknown): IV_t { + if (!isIV(value)) { + throw new Error('Invalid IV.'); + } + return value; +} + +function asCiphertext(value: unknown): Ciphertext_t { + if (!isCiphertext(value)) { + throw new Error('Invalid ciphertext.'); + } + return value; +} + +function asPlaintext(value: unknown): Plaintext_t { + if (!isPlaintext(value)) { + throw new Error('Invalid plaintext.'); + } + return value; +} + +function asSymmetricKey(value: unknown): SymmetricKey_t { + if (!isSymmetricKey(value)) { + throw new Error('Invalid symmetric key.'); + } + return value; +} + +function asPassphraseEncryptionOutput( + value: unknown, +): PassphraseEncryptionOutput_t { + if (!isPassphraseEncryptionOutput(value)) { + throw new Error('Invalid encryption output.'); + } + return value; +} + +/** + * Validates and brands a given value as a properly-encoded encryption output. + * + * @param value the value to validate and brand + * @returns the branded value + */ +function asEncodedPassphraseEncryptionOutput( + value: unknown, +): EncodedPassphraseEncryptionOutput_t { + if (!isEncodedPassphraseEncryptionOutput(value)) { + throw new Error('Invalid encoded encryption output.'); + } + return value; +} + +/* + * Utility Functions for Generating Cryptographic Values + */ + +function generateIV(): IV_t { + return asIV(crypto.randomBytes(AES_256_GCM_ENCRYPTION.IV_LENGTH_BYTES)); +} + +function generateSalt(): Salt_t { + return asSalt( + crypto.randomBytes(PBKDF2_HMAC_SHA256_KEY_DERIVATION.SALT_LENGTH_BYTES), + ); +} + +/* + * Core Cryptographic Functions + */ + +function deriveKeyFromPassphrase( + passphrase: Passphrase_t, + salt: Salt_t, +): KeyDerivationOutput_t { + return { + key: asSymmetricKey( + crypto.pbkdf2Sync( + passphrase, + salt, + PBKDF2_HMAC_SHA256_KEY_DERIVATION.ITERATIONS, + PBKDF2_HMAC_SHA256_KEY_DERIVATION.KEY_LENGTH_BYTES, + PBKDF2_HMAC_SHA256_KEY_DERIVATION.DIGEST, + ), + ), + }; +} + +function encryptWithKey( + key: SymmetricKey_t, + plaintext: Plaintext_t, +): KeyEncryptionOutput_t { + const iv = generateIV(); + const cipher = crypto.createCipheriv( + AES_256_GCM_ENCRYPTION.ALGORITHM, + key, + iv, + { + authTagLength: AES_256_GCM_ENCRYPTION.TAG_LENGTH_BYTES, + }, + ); + const ciphertext = asCiphertext( + Buffer.concat([cipher.update(plaintext), cipher.final()]), + ); + const tag = asTag(cipher.getAuthTag()); + return { + tag, + iv, + ciphertext, + }; +} + +function decryptWithKey( + key: SymmetricKey_t, + iv: IV_t, + tag: Tag_t, + ciphertext: Ciphertext_t, +): KeyDecryptionOutput_t { + const decipher = crypto.createDecipheriv( + AES_256_GCM_ENCRYPTION.ALGORITHM, + key, + iv, + { + authTagLength: tag.length, + }, + ); + decipher.setAuthTag(tag); + const plaintext = asPlaintext( + Buffer.concat([decipher.update(ciphertext), decipher.final()]), + ); + return { plaintext }; +} + +function encryptWithPassphrase( + passphrase: Passphrase_t, + plaintext: Plaintext_t, +): PassphraseEncryptionOutput_t { + const salt = generateSalt(); + const { key } = deriveKeyFromPassphrase(passphrase, salt); + const { tag, iv, ciphertext } = encryptWithKey(key, plaintext); + return { salt, iv, tag, ciphertext }; +} + +function decryptWithPassphrase( + passphrase: Passphrase_t, + salt: Salt_t, + iv: IV_t, + tag: Tag_t, + ciphertext: Ciphertext_t, +): PassphraseDecryptionOutput_t { + const { key } = deriveKeyFromPassphrase(passphrase, salt); + return decryptWithKey(key, iv, tag, ciphertext); +} + +/* + * Encoding and Decoding Functions + */ + +function encodeEncryptedToken( + salt: Salt_t, + iv: IV_t, + tag: Tag_t, + ciphertext: Ciphertext_t, +): string { + const encodedSalt = salt.toString('base64'); + const encodedIV = iv.toString('base64'); + const encodedTag = tag.toString('base64'); + const encodedCiphertext = ciphertext.toString('base64'); + return `${encodedSalt}.${encodedIV}.${encodedTag}.${encodedCiphertext}`; +} + +function decodeEncryptedToken( + encodedToken: EncodedPassphraseEncryptionOutput_t, +): PassphraseEncryptionOutput_t { + const parts = encodedToken.split('.'); + if (parts.length !== 4) { + throw new Error('Expected 4 components in encoded token.'); + } + const [encodedSalt, encodedIV, encodedTag, encodedCiphertext] = parts; + const salt = asSalt(Buffer.from(encodedSalt, 'base64')); + const iv = asIV(Buffer.from(encodedIV, 'base64')); + const tag = asTag(Buffer.from(encodedTag, 'base64')); + const ciphertext = asCiphertext(Buffer.from(encodedCiphertext, 'base64')); + return { salt, iv, tag, ciphertext }; +} + +function encodeEncryptionOutput( + encryptionOutput: PassphraseEncryptionOutput_t, +): EncodedPassphraseEncryptionOutput_t { + return asEncodedPassphraseEncryptionOutput( + encodeEncryptedToken( + encryptionOutput.salt, + encryptionOutput.iv, + encryptionOutput.tag, + encryptionOutput.ciphertext, + ), + ); +} + +function decodeEncryptionOutput( + encodedOutput: EncodedPassphraseEncryptionOutput_t, +): PassphraseEncryptionOutput_t { + return asPassphraseEncryptionOutput(decodeEncryptedToken(encodedOutput)); +} + +/* + * API for encrypting and decrypting OAuth tokens + */ + +/** + * Encrypt the OAuth token using the embed secret. + * + * @param embedSecret the embed secret to use for encryption + * @param oauthToken the OAuth token to encrypt + * @returns the encrypted token, encoded as a string + */ +export function encrypt( + embedSecret: string, + oauthToken: string, +): string { + const passphrase = asPassphrase(Buffer.from(embedSecret, 'utf8')); + const plaintext = asPlaintext(Buffer.from(oauthToken, 'utf8')); + const encryptionOutput = encryptWithPassphrase(passphrase, plaintext); + return encodeEncryptionOutput(encryptionOutput); +} + +/** + * Decrypt the OAuth token using the embed secret. + * + * @param embedSecret the embed secret to use for decryption + * @param encryptedToken the encrypted OAuth token to decrypt + * @returns the decrypted token + */ +export function decrypt( + embedSecret: string, + encryptedToken: string, +): string { + const passphrase = asPassphrase(Buffer.from(embedSecret, 'utf8')); + const encryptionOutput = decodeEncryptionOutput( + asEncodedPassphraseEncryptionOutput(encryptedToken), + ); + const { plaintext } = decryptWithPassphrase( + passphrase, + encryptionOutput.salt, + encryptionOutput.iv, + encryptionOutput.tag, + encryptionOutput.ciphertext, + ); + return plaintext.toString('utf8'); +} diff --git a/packages/node-embed-sdk/src/index.ts b/packages/node-embed-sdk/src/index.ts new file mode 100644 index 0000000..73ebae8 --- /dev/null +++ b/packages/node-embed-sdk/src/index.ts @@ -0,0 +1 @@ +export * from './encryption'; diff --git a/packages/node-embed-sdk/tsconfig.json b/packages/node-embed-sdk/tsconfig.json new file mode 100644 index 0000000..595f168 --- /dev/null +++ b/packages/node-embed-sdk/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/node-embed-sdk/tsconfig.lint.json b/packages/node-embed-sdk/tsconfig.lint.json new file mode 100644 index 0000000..0b3de5e --- /dev/null +++ b/packages/node-embed-sdk/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "@sigmacomputing/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/node-embed-sdk/tsup.config.js b/packages/node-embed-sdk/tsup.config.js new file mode 100644 index 0000000..bb41745 --- /dev/null +++ b/packages/node-embed-sdk/tsup.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + sourcemap: false, + minify: false, + dts: true, + clean: true, + format: ["esm", "cjs"], +}); diff --git a/packages/node-embed-sdk/turbo.json b/packages/node-embed-sdk/turbo.json new file mode 100644 index 0000000..52e8c76 --- /dev/null +++ b/packages/node-embed-sdk/turbo.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "//" + ], + "tasks": { + "build": { + "outputs": [ + "dist/**" + ] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa123f3..8c1867b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,18 @@ importers: specifier: workspace:* version: link:../../tooling/typescript-config + packages/node-embed-sdk: + devDependencies: + '@sigmacomputing/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint-config + '@sigmacomputing/typescript-config': + specifier: workspace:* + version: link:../../tooling/typescript-config + '@types/node': + specifier: ^20.17.16 + version: 20.17.16 + packages/react-embed-sdk: dependencies: '@sigmacomputing/embed-sdk': @@ -5332,7 +5344,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -5377,7 +5389,7 @@ snapshots: is-glob: 4.0.3 stable-hash: 0.0.4 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -5453,7 +5465,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8