Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ jobs:
- run: pnpm install

- run: pnpm run build

- run: pnpm run test
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"typecheck": "turbo run typecheck",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "turbo run test"
},
"author": "sigmacomputing",
"license": "ISC",
Expand Down
2 changes: 1 addition & 1 deletion packages/embed-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"lint": "eslint . --ext .ts",
"watch": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Warn: no test specified\""
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down
28 changes: 26 additions & 2 deletions packages/node-embed-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"lint": "eslint . --ext .ts",
"watch": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "jest"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand All @@ -46,9 +46,33 @@
"devDependencies": {
"@sigmacomputing/eslint-config": "workspace:*",
"@sigmacomputing/typescript-config": "workspace:*",
"@types/node": "^20.17.16"
"@swc/core": "^1.11.29",
"@swc/jest": "^0.2.38",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.16",
"jest": "^29.7.0"
},
"engines": {
"node": ">=18"
},
"jest": {
"transform": {
"^.+\\.tsx?$": [
"@swc/jest",
{
"module": {
"type": "commonjs"
},
"sourceMaps": "inline"
}
]
},
"testEnvironment": "node",
"testMatch": [
"**/__tests__/**.(i|)(spec|test).(j|t)s(x|)"
],
"testPathIgnorePatterns": [
"<rootDir>/dist/"
]
}
}
66 changes: 66 additions & 0 deletions packages/node-embed-sdk/src/__tests__/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { decrypt, encrypt } from "../index";
import { _testExports } from "../encryption";

const EMBED_SECRET = "my fake embed secret";

describe("oauth token encryption", () => {
it("can encrypt and decrypt using a passphrase", () => {
const plaintext = "hello, world!";

// Should be able to encrypt and then immediately decrypt.
const encryptedToken = encrypt(EMBED_SECRET, plaintext);
const decryptedToken = decrypt(EMBED_SECRET, encryptedToken);
expect(decryptedToken).toBe(plaintext);
});

function toEncodedString(
salt: string,
iv: string,
tag: string,
ciphertext: string,
): string {
return `${salt}.${iv}.${tag}.${ciphertext}`;
}

it("only throws error when reading an incorrectly encoded string", () => {
// Throws for invalid format.
expect(() => {
_testExports.asEncodedPassphraseEncryptionOutput("hello, world!");
}).toThrow();

// Throws for valid format, but with non-base64 components.
expect(() => {
_testExports.asEncodedPassphraseEncryptionOutput(
toEncodedString("(salt)", "(iv)", "(tag)", "(ciphertext)"),
);
}).toThrow();

// Throws for valid format with base64 components of invalid length.
expect(() => {
_testExports.asEncodedPassphraseEncryptionOutput(
toEncodedString("YQ==", "Yg==", "Yw==", "ZA=="),
);
}).toThrow();

// Does not throw for valid format with base64 components of valid length.
const salt = Buffer.from(
"s".repeat(
_testExports.PBKDF2_HMAC_SHA256_KEY_DERIVATION.SALT_LENGTH_BYTES,
),
).toString("base64");
const iv = Buffer.from(
"i".repeat(_testExports.AES_256_GCM_ENCRYPTION.IV_LENGTH_BYTES),
).toString("base64");
const tag = Buffer.from(
"t".repeat(_testExports.AES_256_GCM_ENCRYPTION.TAG_LENGTH_BYTES),
).toString("base64");
const ciphertext = Buffer.from(
"c".repeat(10 /* arbitrary length */),
).toString("base64");
expect(() => {
_testExports.asEncodedPassphraseEncryptionOutput(
toEncodedString(salt, iv, tag, ciphertext),
);
}).not.toThrow();
});
});
90 changes: 45 additions & 45 deletions packages/node-embed-sdk/src/encryption.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import crypto from 'node:crypto';
import crypto from "node:crypto";

/*
* Configuration Constants
Expand All @@ -16,7 +16,7 @@ import crypto from 'node:crypto';
* The key length is 256 bits since we are using AES-256-GCM.
*/
const PBKDF2_HMAC_SHA256_KEY_DERIVATION = {
DIGEST: 'sha256',
DIGEST: "sha256",
ITERATIONS: 600_000,
KEY_LENGTH_BYTES: 32, // 256 bits
SALT_LENGTH_BYTES: 16, // 128 bits
Expand All @@ -30,7 +30,7 @@ const PBKDF2_HMAC_SHA256_KEY_DERIVATION = {
* recommendations.
*/
const AES_256_GCM_ENCRYPTION = {
ALGORITHM: 'aes-256-gcm',
ALGORITHM: "aes-256-gcm",
IV_LENGTH_BYTES: 12, // 96 bits
TAG_LENGTH_BYTES: 16, // 128 bits
} as const;
Expand Down Expand Up @@ -157,12 +157,12 @@ function isPassphraseEncryptionOutput(
value: unknown,
): value is PassphraseEncryptionOutput_t {
// The input should be a non-null object
if (!(value && typeof value === 'object')) return false;
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;
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;
Expand All @@ -174,14 +174,14 @@ function isPassphraseEncryptionOutput(
function isEncodedPassphraseEncryptionOutput(
value: unknown,
): value is EncodedPassphraseEncryptionOutput_t {
if (typeof value !== 'string') return false;
const parts = value.split('.');
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;
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;
}

Expand All @@ -191,49 +191,49 @@ function isEncodedPassphraseEncryptionOutput(

function asPassphrase(value: unknown): Passphrase_t {
if (!isPassphrase(value)) {
throw new Error('Invalid passphrase.');
throw new Error("Invalid passphrase.");
}
return value;
}

function asSalt(value: unknown): Salt_t {
if (!isSalt(value)) {
throw new Error('Invalid salt.');
throw new Error("Invalid salt.");
}
return value;
}

function asTag(value: unknown): Tag_t {
if (!isTag(value)) {
throw new Error('Invalid tag.');
throw new Error("Invalid tag.");
}
return value;
}

function asIV(value: unknown): IV_t {
if (!isIV(value)) {
throw new Error('Invalid IV.');
throw new Error("Invalid IV.");
}
return value;
}

function asCiphertext(value: unknown): Ciphertext_t {
if (!isCiphertext(value)) {
throw new Error('Invalid ciphertext.');
throw new Error("Invalid ciphertext.");
}
return value;
}

function asPlaintext(value: unknown): Plaintext_t {
if (!isPlaintext(value)) {
throw new Error('Invalid plaintext.');
throw new Error("Invalid plaintext.");
}
return value;
}

function asSymmetricKey(value: unknown): SymmetricKey_t {
if (!isSymmetricKey(value)) {
throw new Error('Invalid symmetric key.');
throw new Error("Invalid symmetric key.");
}
return value;
}
Expand All @@ -242,7 +242,7 @@ function asPassphraseEncryptionOutput(
value: unknown,
): PassphraseEncryptionOutput_t {
if (!isPassphraseEncryptionOutput(value)) {
throw new Error('Invalid encryption output.');
throw new Error("Invalid encryption output.");
}
return value;
}
Expand All @@ -257,7 +257,7 @@ function asEncodedPassphraseEncryptionOutput(
value: unknown,
): EncodedPassphraseEncryptionOutput_t {
if (!isEncodedPassphraseEncryptionOutput(value)) {
throw new Error('Invalid encoded encryption output.');
throw new Error("Invalid encoded encryption output.");
}
return value;
}
Expand Down Expand Up @@ -373,25 +373,25 @@ function encodeEncryptedToken(
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');
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('.');
const parts = encodedToken.split(".");
if (parts.length !== 4) {
throw new Error('Expected 4 components in encoded token.');
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'));
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 };
}

Expand Down Expand Up @@ -425,12 +425,9 @@ function decodeEncryptionOutput(
* @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'));
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);
}
Expand All @@ -442,11 +439,8 @@ export function encrypt(
* @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'));
export function decrypt(embedSecret: string, encryptedToken: string): string {
const passphrase = asPassphrase(Buffer.from(embedSecret, "utf8"));
const encryptionOutput = decodeEncryptionOutput(
asEncodedPassphraseEncryptionOutput(encryptedToken),
);
Expand All @@ -457,5 +451,11 @@ export function decrypt(
encryptionOutput.tag,
encryptionOutput.ciphertext,
);
return plaintext.toString('utf8');
return plaintext.toString("utf8");
}

export const _testExports = {
PBKDF2_HMAC_SHA256_KEY_DERIVATION,
AES_256_GCM_ENCRYPTION,
asEncodedPassphraseEncryptionOutput,
};
2 changes: 1 addition & 1 deletion packages/node-embed-sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './encryption';
export { encrypt, decrypt } from "./encryption";
2 changes: 1 addition & 1 deletion packages/node-embed-sdk/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022"],
"outDir": "./dist",
"types": ["node"]
"types": ["node", "jest"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
Expand Down
2 changes: 1 addition & 1 deletion packages/react-embed-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint . --ext .ts,.tsx",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Warn: no test specified\""
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down
Loading