diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c8377b8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# Contributing Guide + +Thank you for your interest in contributing to the Phase Node.js SDK! We welcome all contributions, whether it's fixing bugs, adding new features, or improving documentation. + +## Getting Started + +1. **Fork the repository** on GitHub. +2. **Clone your fork** to your local machine: + ```sh + git clone https://github.com/your-username/node-sdk.git + cd node-sdk + ``` +3. **Install dependencies**: + ```sh + yarn install + ``` +4. **Build the package**: + ```sh + yarn build + ``` +5. **Run tests**: + ```sh + yarn test + ``` + +## Making Changes + +- Follow the existing code style (Prettier and ESLint are configured). +- Write clear commit messages following the [Conventional Commits](https://www.conventionalcommits.org/) format. +- Ensure all tests pass before submitting a pull request. +- If adding a new feature, consider writing tests to cover your changes. +- Consider if your changes require an update to [docs](https://github.com/phasehq/docs). +- Bump the package version in `package.json` and `version.ts`. Use the semver standard to bump the major, minor or patch version depending on the type of change you're making. + +## Setting Up a Test Project + +To test your local changes in a real project, follow these steps: + +1. **Create a new test project:** + ```sh + mkdir test-project && cd test-project + yarn init -y + ``` + +2. **Link the local SDK package:** + In the SDK root, run: + ```sh + yarn link + ``` + Then in your test project, + ```sh + yarn link '@phase.dev/phase-node' + ``` + + +3. **Use the SDK in your test project:** + ```js + const Phase = require('@phase.dev/phase-node') + ``` + +## Submitting a Pull Request + +1. **Create a new branch:** + ```sh + git checkout -b feature/your-feature + ``` +2. **Make and commit your changes:** + ```sh + git commit -m "feat: add new feature" + ``` +3. **Push to your fork:** + ```sh + git push origin feature/your-feature + ``` +4. **Open a Pull Request** on GitHub against the `main` branch. + +## Useful Links + +[Phase Quickstart](https://docs.phase.dev/quickstart) + +[SDK Docs](https://docs.phase.dev/sdks/node) + +[Docs repo](https://github.com/phasehq/docs) + +[Community Slack](https://slack.phase.dev) + + diff --git a/README.md b/README.md index b3e084e..4c8270f 100644 --- a/README.md +++ b/README.md @@ -14,36 +14,108 @@ const Phase = require("@phase.dev/phase-node"); ## Initialize -Initialize the SDK with your `APP_ID` and `APP_SECRET`: +Initialize the SDK with your PAT or service account token: -```js -const phase = new Phase(APP_ID, APP_SECRET); +```typescript +const token = 'pss_service...' + +const phase = new Phase(token) ``` ## Usage -### Encrypt +### Get Secrets -```js -const ciphertext = await phase.encrypt("hello world"); +Get all secrets in an environment: + +```typescript +const getOptions: GetSecretOptions = { + appId: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + envName: "Development", +}; + +const secrets = await phase.get(getOptions); ``` -### Decrypt +Get a specific key: -```js -const plaintext = await phase.decrypt(ciphertext); +```typescript +const getOptions: GetSecretOptions = { + appId: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + envName: "Development", + key: "foo" +}; + +const secrets = await phase.get(getOptions); ``` -## Development +### Create Secrets + +Create one or more secrets in a specified application and environment: + +```typescript +import { CreateSecretOptions } from "phase"; + +const createOptions: CreateSecretOptions = { + appId: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + envName: "Development", + secrets: [ + { + key: "API_KEY", + value: "your-api-key", + comment: 'test key for dev' + }, + { + key: "DB_PASSWORD", + value: "your-db-password", + path: "/database", + } + ] +}; + +await phase.create(createOptions); +``` + +### Update Secrets -### Install dependencies +Update existing secrets in a specified application and environment: -`npm install` -### Build -`npm run build` +```typescript +import { UpdateSecretOptions } from "phase"; -### Run tests +const updateOptions: UpdateSecretOptions = { + appId: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + envName: "Development", + secrets: [ + { + id: "28f5d66e-b006-4d34-8e32-88e1d3478299", + value: 'newvalue' + }, + ], +}; + +await phase.update(updateOptions); +``` +### Delete Secrets + +Delete one or more secrets from a specified application and environment: + +```typescript +import { DeleteSecretOptions } from "phase"; + +const secretsToDelete = secrets.map((secret) => secret.id); + +const deleteOptions: DeleteSecretOptions = { + appId: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + envName: "Development", + secretIds: secretsToDelete, +}; + +await phase.delete(deleteOptions); +``` + +## Development -`npm test` +Please see [CONTRIBUTING.md](CONTRIBUTING.md) for more information on how to contribute. diff --git a/package.json b/package.json index 196d798..ffa0e5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@phase.dev/phase-node", - "version": "2.1.0", + "version": "3.0.0", "description": "Node.js Server SDK for Phase", "main": "dist/index.js", "types": "dist/src/index.d.ts", @@ -30,6 +30,7 @@ "@types/libsodium-wrappers": "^0.7.10", "@types/node": "^18.13.0", "@typescript-eslint/eslint-plugin": "^5.0.0", + "axios-mock-adapter": "^2.1.0", "babel-preset-es2015": "^6.24.1", "eslint": "^8.0.1", "eslint-config-prettier": "^8.6.0", @@ -48,7 +49,9 @@ "typescript": "^4.9.5" }, "dependencies": { - "libsodium-wrappers": "^0.7.11" + "axios": "^1.7.9", + "libsodium-wrappers": "^0.7.11", + "ts-node": "^10.9.2" }, "publishConfig": { "ignore": [ diff --git a/src/index.ts b/src/index.ts index 04e7a7c..e3c8b25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,149 +1,430 @@ -const _sodium = require("libsodium-wrappers"); -import { fetchAppKeyShare } from "./utils/wrappedShare"; +import axios from "axios"; import { - clientSessionKeys, - decryptString, - encryptString, - randomKeyPair, - reconstructSecret, - serverSessionKeys, + App, + Environment, + GetSecretOptions, + PhaseKeyPair, + Secret, + CreateSecretOptions, + SessionResponse, + DeleteSecretOptions, + UpdateSecretOptions, +} from "./types"; +import { + normalizeKey, + resolveSecretReferences, + SecretFetcher, +} from "./utils/secretReferencing"; +import { LIB_VERSION } from "../version"; +import { + reconstructPrivateKey, + unwrapEnvKeys, + digest, + decryptEnvSecrets, + encryptEnvSecrets, } from "./utils/crypto"; -type PhaseCiphertext = `ph:${string}:${string}:${string}:${string}`; -type PhaseAppId = `phApp:${string}:${string}`; -type PhaseAppSecret = `pss:${string}:${string}:${string}${string}`; - -const PH_VERSION = "v1"; -const DEFAULT_KMS_HOST = "https://kms.phase.dev"; +const DEFAULT_HOST = "https://console.phase.dev"; export default class Phase { - appId: string; - appPubKey: string; - appSecret: { - prefix: string; - pssVersion: string; - appToken: string; - keyshare0: string; - keyshare1UnwrapKey: string; - }; - kmsHost: string; + token: string; + host: string; + tokenType: string | null = null; + version: string | null = null; + bearerToken: string | null = null; + keypair: PhaseKeyPair = {} as PhaseKeyPair; + apps: App[] = []; - constructor(appId: string, appSecret: string, kmsHost?: string) { - const appIdRegex = /^phApp:v(\d+):([a-fA-F0-9]{64})$/; - const appSecretRegex = - /^pss:v(\d+):([a-fA-F0-9]{64}):([a-fA-F0-9]{64,128}):([a-fA-F0-9]{64})/gm; + constructor(token: string, host?: string) { + this.host = host || DEFAULT_HOST; + this.token = token; + this.validateAndParseToken(token); + } - if (!appIdRegex.test(appId)) { - throw new Error("Invalid Phase appID"); - } + /** + * Initializes the session by fetching data from the server and reconstructing the private key. + * This should be called after the constructor. + */ + public async init(): Promise { + try { + // Fetch wrapped key data, apps, and envs for this token + const response = await axios.get(`${this.host}/service/secrets/tokens/`, { + headers: this.getAuthHeaders(), + }); + + const data: SessionResponse = response.data; + + // Set the keypair for the class instance + this.keypair = { + publicKey: this.token.split(":")[3], + privateKey: await reconstructPrivateKey( + data.wrapped_key_share, + this.token + ), + }; - this.appId = appId; - this.appPubKey = appId.split(":")[2]; - this.kmsHost = kmsHost ? `${kmsHost}/kms` : DEFAULT_KMS_HOST; + const appPromises: Promise[] = data.apps.map(async (app) => { + const { id, name } = app; + + const environmentPromises: Promise[] = + app.environment_keys.map(async (envData) => { + const { publicKey, privateKey, salt } = await unwrapEnvKeys( + envData.wrapped_seed, + envData.wrapped_salt, + this.keypair + ); + + const { id, name } = envData.environment; + + const { paths } = envData; + + return { + id, + name, + paths, + keypair: { + publicKey, + privateKey, + }, + salt, + }; + }); + + const environments = await Promise.all(environmentPromises); + + return { id, name, environments }; + }); + + const apps: App[] = await Promise.all(appPromises); + this.apps = apps; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + throw `Error: ${error.response.status}: ${ + error.response.data?.error || "" + }`; + } + throw new Error( + "Failed to initialize session. Please check your token and network connection." + ); + } + } - const appSecretSegments = appSecret.split(":"); + /** + * Validates and parses the token. + * Throws an error if the token is invalid. + * Sets the following class properties: tokenType, version, bearerToken, + */ + private validateAndParseToken(token: string): void { + // Regex to match the token format + const tokenRegex = + /^(pss_(user|service)):v\d+:[a-fA-F0-9]{64}:[a-fA-F0-9]{64}:[a-fA-F0-9]{64}:[a-fA-F0-9]{64}$/; - if (!appSecretRegex.test(appSecret)) { - throw new Error("Invalid Phase AppSecret"); + // Test the token against the regex + if (!tokenRegex.test(token)) { + throw new Error( + "Invalid token format. Token does not match the expected pattern." + ); } - this.appSecret = { - prefix: appSecretSegments[0], - pssVersion: appSecretSegments[1], - appToken: appSecretSegments[2], - keyshare0: appSecretSegments[3], - keyshare1UnwrapKey: appSecretSegments[4], + // Split the token into its components + const [tokenType, version, bearerToken] = token.split(":"); + + // Assign the parsed values to the instance properties + this.tokenType = tokenType.includes("user") + ? "User" + : version === "v1" + ? "Service" + : "ServiceAccount"; + this.version = version; + this.bearerToken = bearerToken; + } + + private getAuthHeaders() { + return { + Authorization: `Bearer ${this.tokenType} ${this.bearerToken}`, + Accept: "application/json", + "X-Use-Camel-Case": true, + "User-agent": `phase-node-sdk/${LIB_VERSION}`, }; } - encrypt = async ( - plaintext: string, - tag: string = "" - ): Promise => { - await _sodium.ready; - const sodium = _sodium; + async get(options: GetSecretOptions): Promise { + return new Promise(async (resolve, reject) => { + const cache = new Map(); + + const app = this.apps.find((app) => app.id === options.appId); + if (!app) { + return reject("Invalid app id"); + } + + const env = app.environments.find( + (e) => e.name.toLowerCase() === options.envName.toLowerCase() + ); + if (!env) { + return reject(`Invalid environment name: ${options.envName}`); + } - return new Promise(async (resolve, reject) => { try { - const oneTimeKeyPair = await randomKeyPair(); + const queryHeaders = { + environment: env.id, + path: options.path, + keyDigest: options.key + ? await digest(options.key.toUpperCase(), env.salt) + : null, + }; - const symmetricKeys = await clientSessionKeys( - oneTimeKeyPair, - sodium.from_hex(this.appPubKey) + const res = await axios.get(`${this.host}/service/secrets/`, { + headers: { ...queryHeaders, ...this.getAuthHeaders() }, + }); + + const secretsToDecrypt: Secret[] = res.data.filter( + (secret: Secret) => + (!options.path || secret.path === options.path) && + (!options.tags || + secret.tags.some((tag) => options.tags?.includes(tag))) + ); + + // Replace the value with the override value if it exists + secretsToDecrypt.forEach( + (secret) => + (secret.value = secret.override?.isActive + ? secret.override.value + : secret.value) ); - const ciphertext = await encryptString( - plaintext, - symmetricKeys.sharedTx + const secrets = await decryptEnvSecrets(secretsToDecrypt, env.keypair); + + // Create lookup map + const secretLookup = new Map( + secrets.map((s) => [normalizeKey(env.name, s.path, s.key), s]) ); - // Use sodium.memzero to wipe the keys from memory - sodium.memzero(oneTimeKeyPair.privateKey); - sodium.memzero(symmetricKeys.sharedTx); - sodium.memzero(symmetricKeys.sharedRx); + // Fetcher for resolving references + const fetcher: SecretFetcher = async (envName, path, key) => { + const cacheKey = normalizeKey(envName, path, key); + + if (cache.has(cacheKey)) { + return { + id: "", + key, + value: cache.get(cacheKey)!, + comment: "", + environment: envName, + folder: undefined, + path, + tags: [], + keyDigest: "", + createdAt: undefined, + updatedAt: new Date().toISOString(), + version: 1, + }; + } + + let secret = secretLookup.get(cacheKey); + if (!secret) { + const crossEnvSecrets = await this.get({ + ...options, + envName, + path, + key, + tags: undefined, + }); + secret = crossEnvSecrets.find((s) => s.key === key); + if (!secret) + throw new Error(`Missing secret: ${envName}:${path}:${key}`); + + secretLookup.set(cacheKey, secret); + } + + cache.set(cacheKey, secret.value); + return secret; + }; - resolve( - `ph:${PH_VERSION}:${sodium.to_hex( - oneTimeKeyPair.publicKey - )}:${ciphertext}:${tag}` + // Resolve references + const resolvedSecrets = await Promise.all( + secrets.map(async (secret) => ({ + ...secret, + value: await resolveSecretReferences( + secret.value, + options.envName, + options.path || "/", + fetcher, + cache + ), + })) ); + + resolve(resolvedSecrets); } catch (error) { - reject(`Something went wrong: ${error}`); + if (axios.isAxiosError(error) && error.response) { + throw `Error: ${error.response.status}: ${ + error.response.data?.error || "" + }`; + } + throw `Error fetching secrets: ${error}`; } }); - }; + } - decrypt = async (phaseCiphertext: PhaseCiphertext): Promise => { - await _sodium.ready; - const sodium = _sodium; + create = async (options: CreateSecretOptions): Promise => { + return new Promise(async (resolve, reject) => { + const { appId, envName } = options; - return new Promise(async (resolve, reject) => { - const ciphertextSegments = phaseCiphertext.split(":"); - if (ciphertextSegments.length !== 5 || ciphertextSegments[0] !== "ph") - reject("Invalid phase ciphertext"); + const app = this.apps.find((app) => app.id === appId); + if (!app) { + throw "Invalid app id"; + } - const ciphertext = { - prefix: ciphertextSegments[0], - pubKey: ciphertextSegments[2], - data: ciphertextSegments[3], - tag: ciphertextSegments[4], - }; + const env = app?.environments.find((env) => env.name === envName); + if (!env) { + throw "Invalid environment name"; + } try { - const keyshare1 = await fetchAppKeyShare( - this.appSecret.appToken, - this.appSecret.keyshare1UnwrapKey, - this.appId, - ciphertext.data.length / 2, - this.kmsHost - ); + if (options.secrets.some((secret) => secret.key?.length === 0)) { + throw "Secret keys cannot be blank"; + } - const appPrivKey = await reconstructSecret([ - this.appSecret.keyshare0, - keyshare1, - ]); - - const sessionKeys = await serverSessionKeys( - { - publicKey: sodium.from_hex(this.appPubKey) as Uint8Array, - privateKey: appPrivKey, - }, - sodium.from_hex(ciphertext.pubKey) + const encryptedSecrets = await encryptEnvSecrets( + options.secrets, + env.keypair, + env.salt ); - const plaintext = await decryptString( - ciphertext.data, - sessionKeys.sharedRx + encryptedSecrets.forEach((secret) => { + if (!secret.tags) secret.tags = []; + if (!secret.path) secret.path = "/"; + }); + + try { + const requestHeaders = { environment: env.id }; + + const requestBody = JSON.stringify({ secrets: encryptedSecrets }); + + try { + const res = await axios({ + url: `${this.host}/service/secrets/`, + method: "post", + headers: { + ...requestHeaders, + ...this.getAuthHeaders(), + }, + data: requestBody, + }); + + if (res.status === 200) resolve(); + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + throw `Error: ${error.response.status}: ${ + error.response.data?.error || "" + }`; + } else { + throw `Unexpected error: ${error}`; + } + } + } catch (err) { + throw(`Error creating secrets: ${err}`); + } + } catch (err) { + throw(`Something went wrong: ${err}`); + + } + }); + }; + + update = async (options: UpdateSecretOptions): Promise => { + return new Promise(async (resolve, reject) => { + const { appId, envName } = options; + + const app = this.apps.find((app) => app.id === appId); + if (!app) { + throw "Invalid app id"; + } + + const env = app?.environments.find((env) => env.name === envName); + if (!env) { + throw "Invalid environment name"; + } + + try { + if (options.secrets.some((secret) => secret.key?.length === 0)) { + throw "Secret keys cannot be blank"; + } + + const encryptedSecrets = await encryptEnvSecrets( + options.secrets, + env.keypair, + env.salt ); - // Use sodium.memzero to wipe the keys from memory - sodium.memzero(sessionKeys.sharedRx); - sodium.memzero(sessionKeys.sharedTx); - sodium.memzero(appPrivKey); + encryptedSecrets.forEach((secret) => { + if (!secret.tags) secret.tags = []; + }); - resolve(plaintext); + try { + const requestHeaders = { environment: env.id }; + + const requestBody = JSON.stringify({ secrets: encryptedSecrets }); + + const res = await axios({ + url: `${this.host}/service/secrets/`, + method: "put", + headers: { + ...requestHeaders, + ...this.getAuthHeaders(), + }, + data: requestBody, + }); + + if (res.status === 200) resolve(); + } catch (err) { + console.log(`Error creating secrets: ${err}`); + } } catch (error) { - reject(`Something went wrong: ${error}`); + if (axios.isAxiosError(error) && error.response) { + throw `Error: ${error.response.status}: ${ + error.response.data?.error || "" + }`; + } + throw `Something went wrong: ${error}`; + } + }); + }; + + delete = async (options: DeleteSecretOptions): Promise => { + return new Promise(async (resolve, reject) => { + if (options.secretIds.length > 0) { + try { + const { appId, envName } = options; + + const app = this.apps.find((app) => app.id === appId); + if (!app) { + throw "Invalid app id"; + } + + const env = app?.environments.find((env) => env.name === envName); + if (!env) { + throw "Invalid environment name"; + } + + const requestHeaders = { environment: env.id }; + + const res = await axios({ + url: `${this.host}/service/secrets/`, + method: "delete", + headers: { + ...requestHeaders, + ...this.getAuthHeaders(), + }, + data: JSON.stringify({ secrets: options.secretIds }), + }); + + if (res.status === 200) resolve(); + } catch (err) { + console.log(`Error deleting secrets: ${err}`); + reject; + return; + } } }); }; diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..e55bd27 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,105 @@ +import { StringifyOptions } from "querystring"; + +export type PhaseKeyPair = { + publicKey: string; + privateKey: string; +}; + +export type EnvironmentKey = { + id: string; + environment: { + id: string; + name: string; + env_type: string; + }; + paths: string[] | null; + identity_key: string; + wrapped_seed: string; + wrapped_salt: string; + created_at: string; + updated_at: string; + deleted_at: string | null; + user: string; + service_account: string | null; +}; + +export type Environment = { + id: string; + name: string; + paths: string[] | null; + keypair: PhaseKeyPair; + salt: string; +}; + +export type AppData = { + id: string; + name: string; + encryption: "SSE" | "E2E"; + environment_keys: EnvironmentKey[]; +}; + +export type App = { + id: string; + name: string; + environments: Environment[]; +}; + +export type SessionResponse = { + wrapped_key_share: string; + apps: AppData[]; +}; + +export type SecretValueOverride = { + value: string + isActive: boolean +} + +export type Secret = { + id: string; + key: string; + value: string; + comment: string; + environment: string; + folder?: string; + path: string; + tags: string[]; + keyDigest: string; + override?: SecretValueOverride; + createdAt?: string; + updatedAt?: string; + version: number; +}; + +export type GetSecretOptions = { + appId: string; + envName: string; + path?: string; + key?: string; + tags?: string[]; +}; + +export type SecretInput = { + key: string; + value: string; + comment: string; + path?: string; + tags?: string[] +}; + +export type CreateSecretOptions = { + appId: string; + envName: string; + secrets: SecretInput[]; +}; + +export type DeleteSecretOptions = { + appId: string; + envName: string; + secretIds: string[]; +}; + +export type UpdateSecretOptions = { + appId: string; + envName: string; + secrets: (Partial & { id: string, override?: SecretValueOverride })[]; +}; diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts deleted file mode 100644 index 0aacc81..0000000 --- a/src/utils/crypto.ts +++ /dev/null @@ -1,182 +0,0 @@ -const _sodium = require("libsodium-wrappers"); -import { KeyPair } from "libsodium-wrappers"; - -/** - * XChaCha20-Poly1305 encrypt - * - * @param {String} plaintext - * @param {Uint8Array} key - * @returns {Uint8Array} - Ciphertext with appended nonce - */ -export const encryptRaw = async (plaintext: String, key: Uint8Array) => { - await _sodium.ready; - const sodium = _sodium; - - let nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); - try { - let ciphertext3 = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( - plaintext, - null, - null, - nonce, - key - ); - return new Uint8Array([...ciphertext3, ...nonce]); - } catch (e) { - throw "Encrypt error"; - } -}; - -/** - * XChaCha20-Poly1305 decrypt - * - * @param {Uint8Array} encryptedMessage - Ciphertext + Nonce - * @param {Uint8Array} key - Decryption key - * @returns {Promise} - */ -export const decryptRaw = async ( - encryptedMessage: Uint8Array, - key: Uint8Array -): Promise => { - await _sodium.ready; - const sodium = _sodium; - - const messageLen = encryptedMessage.length - 24; - const nonce = encryptedMessage.slice(messageLen); - const ciphertext = encryptedMessage.slice(0, messageLen); - - try { - const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( - null, - ciphertext, - null, - nonce, - key - ); - - return plaintext; - } catch (e) { - throw "Decrypt error"; - } -}; - -/** - * Encrypts a single string with the given key. Returns the ciphertext as a hex string - * - * @param {string} plaintext - Plaintext string to encrypt - * @param {Uint8Array} key - Symmetric encryption key - * @returns {string} - */ -export const encryptString = async (plaintext: string, key: Uint8Array) => { - await _sodium.ready; - const sodium = _sodium; - - return sodium.to_base64( - await encryptRaw(sodium.from_string(plaintext), key), - sodium.base64_variants.ORIGINAL - ); -}; - -/** - * Decrypts a single hex ciphertext string with the given key. Returns the plaintext as a string - * - * @param cipherText - Hex string ciphertext with appended nonce - * @param key - Symmetric encryption key - * @returns {string} - */ -export const decryptString = async (cipherText: string, key: Uint8Array) => { - await _sodium.ready; - const sodium = _sodium; - - return sodium.to_string( - await decryptRaw( - sodium.from_base64(cipherText, sodium.base64_variants.ORIGINAL), - key - ) - ); -}; - -/** - * Returns an random key exchange keypair - * - * @returns {KeyPair} - */ -export const randomKeyPair = async () => { - await _sodium.ready; - const sodium = _sodium; - const keypair = await sodium.crypto_kx_keypair(); - - return keypair; -}; - -/** - * Carries out diffie-hellman key exchange for client and returns a pair of symmetric encryption keys - * - * @param {KeyPair} ephemeralKeyPair - * @param {Uint8Array} recipientPubKey - * @returns - */ -export const clientSessionKeys = async ( - ephemeralKeyPair: KeyPair, - recipientPubKey: Uint8Array -) => { - await _sodium.ready; - const sodium = _sodium; - - const keys = await sodium.crypto_kx_client_session_keys( - ephemeralKeyPair.publicKey, - ephemeralKeyPair.privateKey, - recipientPubKey - ); - return keys; -}; - -/** - * Carries out diffie-hellman key exchange for server and returns a pair of symmetric encryption keys - * - * @param {KeyPair} ephemeralKeyPair - * @param {Uint8Array} recipientPubKey - * @returns - */ -export const serverSessionKeys = async ( - appKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, - dataPubKey: Uint8Array -) => { - await _sodium.ready; - const sodium = _sodium; - const keys = await sodium.crypto_kx_server_session_keys( - appKeyPair.publicKey, - appKeyPair.privateKey, - dataPubKey - ); - return keys; -}; - -/** - * Computes the xor of two Uint8Arrays, byte by byte and returns the result - * - * @param {Uint8Array} a - * @param {Uint8Array} b - * @returns {Uint8Array} The xor of Uint8Arrays a and b - */ -const xorUint8Arrays = (a: Uint8Array, b: Uint8Array): Uint8Array => { - return Uint8Array.from(a.map((byte, i) => byte ^ b[i])); -}; - -/** - * Reconstructs a secret given an array of shares - * - * @param {string[]} shares Array of shares encoded as hex string - * @returns {Uint8Array} The reconstructed secret - */ -export const reconstructSecret = async ( - shares: string[] -): Promise => { - await _sodium.ready; - const sodium = _sodium; - const byteShares = shares.map((share) => sodium.from_hex(share)); - - const secret = byteShares.reduce((prev, curr) => xorUint8Arrays(prev, curr)); - - return secret; -}; diff --git a/src/utils/crypto/constants.ts b/src/utils/crypto/constants.ts new file mode 100644 index 0000000..8bb6dbb --- /dev/null +++ b/src/utils/crypto/constants.ts @@ -0,0 +1 @@ +export const VERSION = 1 \ No newline at end of file diff --git a/src/utils/crypto/general.ts b/src/utils/crypto/general.ts new file mode 100644 index 0000000..7c35f48 --- /dev/null +++ b/src/utils/crypto/general.ts @@ -0,0 +1,247 @@ +const _sodium = require("libsodium-wrappers"); +import { KeyPair } from "libsodium-wrappers"; +import { VERSION } from "./constants"; + +/** + * XChaCha20-Poly1305 encrypt + * + * @param {String} plaintext + * @param {Uint8Array} key + * @returns {Promise} - Ciphertext with appended nonce + */ +export const encryptRaw = async (plaintext: string, key: Uint8Array): Promise => { + await _sodium.ready + const sodium = _sodium + + let nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) + let ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( + plaintext, + null, + null, + nonce, + key + ) + return new Uint8Array([...ciphertext, ...nonce]) +} + +/** + * XChaCha20-Poly1305 decrypt + * + * @param {Uint8Array} encryptedMessage - Ciphertext + Nonce + * @param {Uint8Array} key - Decryption key + * @returns {Promise} + */ +export const decryptRaw = async ( + encryptedMessage: Uint8Array, + key: Uint8Array +): Promise => { + await _sodium.ready + const sodium = _sodium + + const messageLen = encryptedMessage.length - 24 + const nonce = encryptedMessage.slice(messageLen) + const ciphertext = encryptedMessage.slice(0, messageLen) + + const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + null, + ciphertext, + null, + nonce, + key + ) + + return plaintext +} + +/** + * Encrypts a single string with the given key. Returns the ciphertext as a hex string + * + * @param {string} plaintext - Plaintext string to encrypt + * @param {Uint8Array} key - Symmetric encryption key + * @returns {string} + */ +export const encryptString = async (plaintext: string, key: Uint8Array) => { + await _sodium.ready; + const sodium = _sodium; + + return sodium.to_base64( + await encryptRaw(sodium.from_string(plaintext), key), + sodium.base64_variants.ORIGINAL + ); +}; + +/** + * Decrypts a single hex ciphertext string with the given key. Returns the plaintext as a string + * + * @param cipherText - Hex string ciphertext with appended nonce + * @param key - Symmetric encryption key + * @returns {string} + */ +export const decryptString = async (cipherText: string, key: Uint8Array) => { + await _sodium.ready; + const sodium = _sodium; + + return sodium.to_string( + await decryptRaw( + sodium.from_base64(cipherText, sodium.base64_variants.ORIGINAL), + key + ) + ); +}; + +/** + * Returns an random key exchange keypair + * + * @returns {KeyPair} + */ +export const randomKeyPair = async () => { + await _sodium.ready; + const sodium = _sodium; + const keypair = await sodium.crypto_kx_keypair(); + + return keypair; +}; + +/** + * Carries out diffie-hellman key exchange for client and returns a pair of symmetric encryption keys + * + * @param {KeyPair} ephemeralKeyPair + * @param {Uint8Array} recipientPubKey + * @returns + */ +export const clientSessionKeys = async ( + ephemeralKeyPair: KeyPair, + recipientPubKey: Uint8Array +) => { + await _sodium.ready; + const sodium = _sodium; + + const keys = await sodium.crypto_kx_client_session_keys( + ephemeralKeyPair.publicKey, + ephemeralKeyPair.privateKey, + recipientPubKey + ); + return keys; +}; + +/** + * Carries out diffie-hellman key exchange for server and returns a pair of symmetric encryption keys + * + * @param {KeyPair} ephemeralKeyPair + * @param {Uint8Array} recipientPubKey + * @returns + */ +export const serverSessionKeys = async ( + appKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, + dataPubKey: Uint8Array +) => { + await _sodium.ready; + const sodium = _sodium; + const keys = await sodium.crypto_kx_server_session_keys( + appKeyPair.publicKey, + appKeyPair.privateKey, + dataPubKey + ); + return keys; +}; + +/** + * Encrypts a string using the given public key + * + * @param {string} plaintext + * @param {string} publicKey - hex encoded public key + * @returns + */ +export const encryptAsymmetric = async ( + plaintext: string, + publicKey: string +): Promise => { + await _sodium.ready; + const sodium = _sodium; + + return new Promise(async (resolve, reject) => { + try { + const oneTimeKeyPair = await randomKeyPair(); + + const symmetricKeys = await clientSessionKeys( + oneTimeKeyPair, + sodium.from_hex(publicKey) + ); + + const ciphertext = await encryptString(plaintext, symmetricKeys.sharedTx); + + // Use sodium.memzero to wipe the keys from memory + sodium.memzero(oneTimeKeyPair.privateKey); + sodium.memzero(symmetricKeys.sharedTx); + sodium.memzero(symmetricKeys.sharedRx); + + resolve( + `ph:v${VERSION}:${sodium.to_hex( + oneTimeKeyPair.publicKey + )}:${ciphertext}` + ); + } catch (error) { + reject(`Something went wrong: ${error}`); + } + }); +}; + +/** + * + * @param ciphertextString + * @param privateKey + * @param publicKey + * @returns + */ +export const decryptAsymmetric = async ( + ciphertextString: string, + privateKey: string, + publicKey: string +): Promise => { + await _sodium.ready; + const sodium = _sodium; + + return new Promise(async (resolve, reject) => { + const ciphertextSegments = ciphertextString.split(":"); + + if (ciphertextSegments.length !== 4) reject("Invalid ciphertext"); + + const ciphertext = { + prefix: ciphertextSegments[0], + version: ciphertextSegments[1], + pubKey: ciphertextSegments[2], + data: ciphertextSegments[3], + }; + + try { + const sessionKeys = await serverSessionKeys( + { + publicKey: sodium.from_hex(publicKey) as Uint8Array, + privateKey: sodium.from_hex(privateKey) as Uint8Array, + }, + sodium.from_hex(ciphertext.pubKey) + ); + + const plaintext = await decryptString( + ciphertext.data, + sessionKeys.sharedRx + ); + + // Use sodium.memzero to wipe the keys from memory + sodium.memzero(sessionKeys.sharedRx); + sodium.memzero(sessionKeys.sharedTx); + + resolve(plaintext); + } catch (error) { + reject(`Something went wrong: ${error}`); + } + }); +}; + +export const digest = async (input: string, salt: string) => { + await _sodium.ready; + const sodium = _sodium; + + const hash = await sodium.crypto_generichash(32, input, salt); + return sodium.to_hex(hash); +}; diff --git a/src/utils/crypto/index.ts b/src/utils/crypto/index.ts new file mode 100644 index 0000000..69566f8 --- /dev/null +++ b/src/utils/crypto/index.ts @@ -0,0 +1,5 @@ +export * from "./general"; +export * from "./constants"; +export * from "./keyHandling"; +export * from "./secrets"; +export * from "./secretSplitting"; diff --git a/src/utils/crypto/keyHandling.ts b/src/utils/crypto/keyHandling.ts new file mode 100644 index 0000000..c062225 --- /dev/null +++ b/src/utils/crypto/keyHandling.ts @@ -0,0 +1,122 @@ +import { PhaseKeyPair, Secret } from "../../types"; +import { decryptAsymmetric, decryptRaw } from "./general"; +import { reconstructSecret } from "./secretSplitting"; + +const _sodium = require("libsodium-wrappers"); + +/** + * Converts a public signing key to to a key-exchange key that can be used for asymmetric encryption + * + * @param {string} signingPublicKey + * @returns {string} + */ +export const getUserKxPublicKey = async (signingPublicKey: string) => { + await _sodium.ready; + const sodium = _sodium; + + return sodium.to_hex( + sodium.crypto_sign_ed25519_pk_to_curve25519( + sodium.from_hex(signingPublicKey) + ) + ); +}; + +/** + * Converts a private signing key to to a key-exchange key that can be used for asymmetric encryption + * + * @param {string} signingPrivateKey + * @returns {string} + */ +export const getUserKxPrivateKey = async (signingPrivateKey: string) => { + await _sodium.ready; + const sodium = _sodium; + + return sodium.to_hex( + sodium.crypto_sign_ed25519_sk_to_curve25519( + sodium.from_hex(signingPrivateKey) + ) + ); +}; + +/** + * Derives an env keyring from the given seed + * + * @param {string} envSeed - Env seed as a hex string + * @returns {Promise} + */ +export const envKeyring = async (envSeed: string): Promise => { + await _sodium.ready; + const sodium = _sodium; + + const seedBytes = sodium.from_hex(envSeed); + const envKeypair = sodium.crypto_kx_seed_keypair(seedBytes); + + const { publicKey, privateKey } = envKeypair; + + return { + publicKey: sodium.to_hex(publicKey), + privateKey: sodium.to_hex(privateKey), + }; +}; + +/** + * Unwraps environment secrets for a user. + * + * @param {string} wrappedSeed - The wrapped environment seed. + * @param {string} wrappedSalt - The wrapped environment salt. + * @param {PhaseKeyPair} keyring - The keyring of the user. + * @returns {Promise<{ publicKey: string; privateKey: string; salt: string }>} - An object containing the unwrapped environment secrets. + */ +export const unwrapEnvKeys = async ( + wrappedSeed: string, + wrappedSalt: string, + keyring: PhaseKeyPair +) => { + const salt = await decryptAsymmetric( + wrappedSalt, + keyring.privateKey, + keyring.publicKey + ); + + const seed = await decryptAsymmetric( + wrappedSeed, + keyring.privateKey, + keyring.publicKey + ); + + const { publicKey, privateKey } = await envKeyring(seed); + + return { + seed, + publicKey, + privateKey, + salt, + }; +}; + + + +export const reconstructPrivateKey = async ( + wrappedKeyShare: string, + token: string +) => { + await _sodium.ready; + const sodium = _sodium; + + const clientKeyShare = token.split(":")[4]; + const wrappingKey = token.split(":")[5]; + + // Decrypt the wrapped key share from the server + const serverKeyShare = await decryptRaw( + sodium.from_hex(wrappedKeyShare), + sodium.from_hex(wrappingKey) // wrappingKey from the token + ); + + // Reconstruct the private key + const privateKey = await reconstructSecret([ + clientKeyShare, // clientKeyShare from the token + sodium.to_string(serverKeyShare), + ]); + + return sodium.to_hex(privateKey); +}; diff --git a/src/utils/crypto/secretSplitting.ts b/src/utils/crypto/secretSplitting.ts new file mode 100644 index 0000000..12cf4b2 --- /dev/null +++ b/src/utils/crypto/secretSplitting.ts @@ -0,0 +1,30 @@ +const _sodium = require("libsodium-wrappers"); + +/** + * Computes the xor of two Uint8Arrays, byte by byte and returns the result + * + * @param {Uint8Array} a + * @param {Uint8Array} b + * @returns {Uint8Array} The xor of Uint8Arrays a and b + */ +const xorUint8Arrays = (a: Uint8Array, b: Uint8Array): Uint8Array => { + return Uint8Array.from(a.map((byte, i) => byte ^ b[i])); +}; + +/** + * Reconstructs a secret given an array of shares + * + * @param {string[]} shares Array of shares encoded as hex string + * @returns {Uint8Array} The reconstructed secret + */ +export const reconstructSecret = async ( + shares: string[] +): Promise => { + await _sodium.ready; + const sodium = _sodium; + const byteShares = shares.map((share) => sodium.from_hex(share)); + + const secret = byteShares.reduce((prev, curr) => xorUint8Arrays(prev, curr)); + + return secret; +}; diff --git a/src/utils/crypto/secrets.ts b/src/utils/crypto/secrets.ts new file mode 100644 index 0000000..ebc632b --- /dev/null +++ b/src/utils/crypto/secrets.ts @@ -0,0 +1,113 @@ +import { PhaseKeyPair, Secret } from "../../types"; +import { encryptAsymmetric, digest, decryptAsymmetric } from "./general"; + + + +/** + * Encrypts environment secret key and value pairs using asymmetric encryption. + * + * @param {Object[]} plaintextSecrets - Array of plaintext secrets to encrypt + * @param {PhaseKeyPair} envKeys - Keypair containing public key for encryption + * @returns {Promise} - Array of encrypted secrets + */ +export const encryptEnvSecrets = async ( + plaintextSecrets: Partial[], + envKeys: PhaseKeyPair, + envSalt: string +): Promise[]> => { + const encryptedSecrets = await Promise.all( + plaintextSecrets.map(async (secret) => { + const encryptedSecret = structuredClone(secret); + + // Encrypt sensitive fields + if (secret.key !== undefined) { + encryptedSecret.key = await encryptAsymmetric( + secret.key!.toUpperCase(), + envKeys.publicKey + ); + encryptedSecret.keyDigest = await digest(secret.key!, envSalt); + } + + if (secret.value !== undefined) { + encryptedSecret.value = await encryptAsymmetric( + secret.value!, + envKeys.publicKey + ); + } + + if (secret.comment !== undefined) { + encryptedSecret.comment = await encryptAsymmetric( + secret.comment, + envKeys.publicKey + ); + } + else { + encryptedSecret.comment = await encryptAsymmetric( + "", + envKeys.publicKey + ); + } + + if (secret.override?.value !== undefined) { + encryptedSecret.override!.value = await encryptAsymmetric( + secret.override.value, + envKeys.publicKey + ); + } + + + + return encryptedSecret; + }) + ); + + return encryptedSecrets; +}; + +/** + * Decrypts environment secret key and value pairs. + * + * @param {Secret[]} encryptedSecrets - An array of encrypted secrets. + * @param {{ publicKey: string; privateKey: string }} envKeys - The environment keys for decryption. + * @returns {Promise} - An array of decrypted secrets. + */ +export const decryptEnvSecrets = async ( + encryptedSecrets: Secret[], + envKeys: { publicKey: string; privateKey: string } +) => { + return Promise.all( + encryptedSecrets.map(async (secret: Secret) => { + try { + const decryptedSecret = structuredClone(secret); + + decryptedSecret.key = await decryptAsymmetric( + secret.key, + envKeys.privateKey, + envKeys.publicKey + ); + + decryptedSecret.value = await decryptAsymmetric( + secret.value, + envKeys.privateKey, + envKeys.publicKey + ); + + decryptedSecret.comment = secret.comment + ? await decryptAsymmetric(secret.comment, envKeys.privateKey, envKeys.publicKey) + : secret.comment; + + if (secret.override) { + decryptedSecret.override!.value = await decryptAsymmetric( + secret.override.value, + envKeys.privateKey, + envKeys.publicKey + ); + } + + return decryptedSecret; + } catch (error) { + throw new Error(`Decryption failed: ${error instanceof Error ? error.message : String(error)}`); + } + }) + ); +}; diff --git a/src/utils/secretReferencing.ts b/src/utils/secretReferencing.ts new file mode 100644 index 0000000..f88c535 --- /dev/null +++ b/src/utils/secretReferencing.ts @@ -0,0 +1,88 @@ +import { Secret } from "../types"; + +type SecretReference = { + env: string | null; + path: string | null; + key: string; +}; + +export type SecretFetcher = ( + env: string, + path: string, + key: string +) => Promise; + +// Regex pattern for secret references +const REFERENCE_REGEX = + /\${(?:(?[^.\/}]+)\.)?(?:(?[^}]+)\/)?(?[^}]+)}/g; + +export const normalizeKey = (env: string, path: string, key: string) => + `${env.toLowerCase()}:${path.replace(/\/+$/, "")}:${key}`; + +export function parseSecretReference(reference: string): SecretReference { + const match = new RegExp(REFERENCE_REGEX.source).exec(reference); + if (!match?.groups) { + throw new Error(`Invalid secret reference format: ${reference}`); + } + + let { env, path, key } = match.groups; + env = env?.trim() || ""; + key = key.trim(); + path = path ? `/${path.replace(/\.+/g, "/")}`.replace(/\/+/g, "/") : "/"; + + return { env, path, key }; +} + +export async function resolveSecretReferences( + value: string, + currentEnv: string, + currentPath: string, + fetcher: SecretFetcher, + cache: Map = new Map(), + resolutionStack: Set = new Set() +): Promise { + const references = Array.from(value.matchAll(REFERENCE_REGEX)); + let resolvedValue = value; + + for (const ref of references) { + try { + const { + env: refEnv, + path: refPath, + key: refKey, + } = parseSecretReference(ref[0]); + const targetEnv = refEnv || currentEnv; + const targetPath = refPath || currentPath; + const cacheKey = normalizeKey(targetEnv, targetPath, refKey); + + if (resolutionStack.has(cacheKey)) { + throw new Error(`Circular reference detected: ${cacheKey}`); + } + + if (!cache.has(cacheKey)) { + resolutionStack.add(cacheKey); + try { + const secret = await fetcher(targetEnv, targetPath, refKey); + const resolvedSecretValue = await resolveSecretReferences( + secret.value, + targetEnv, + targetPath, + fetcher, + cache, + resolutionStack + ); + cache.set(cacheKey, resolvedSecretValue); + } finally { + resolutionStack.delete(cacheKey); + } + } + + resolvedValue = resolvedValue.replace(ref[0], cache.get(cacheKey)!); + } catch (error) { + console.error(`Error resolving reference ${ref[0]}:`, error); + throw error; + } + } + + return resolvedValue; +} diff --git a/src/utils/wrappedShare.ts b/src/utils/wrappedShare.ts deleted file mode 100644 index 7d3d1e4..0000000 --- a/src/utils/wrappedShare.ts +++ /dev/null @@ -1,48 +0,0 @@ -const _sodium = require("libsodium-wrappers"); -import { LIB_VERSION } from "./../../version"; -import { decryptRaw } from "./crypto"; - -/** - * Fetches and unwraps an app key share from the phase backend - * - * @param {string} appToken - * @param {string} wrapKey - * @returns {string} - Unwrapped app key share - */ -export const fetchAppKeyShare = async ( - appToken: string, - wrapKey: string, - appId: string, - dataSize: number, - host: string -) => { - await _sodium.ready; - const sodium = _sodium; - - const PHASE_KMS_URI = `${host}/${appId}`; - - const headers = { - Authorization: `Bearer ${appToken}`, - EventType: "decrypt", - PhaseNode: `node-js:${LIB_VERSION}`, - PhSize: `${dataSize}`, - }; - - return new Promise((resolve, reject) => { - fetch(PHASE_KMS_URI, { - headers, - }).then((response) => { - if (response.status === 404) reject("Invalid app token"); - else { - response.json().then(async (json) => { - const wrappedKeyShare = json.wrappedKeyShare; - const wrappedKeyBytes = sodium.from_hex(wrappedKeyShare); - const keyBytes = sodium.from_hex(wrapKey); - - const unwrappedKey = await decryptRaw(wrappedKeyBytes, keyBytes); - resolve(sodium.to_string(unwrappedKey)); - }); - } - }); - }); -}; diff --git a/tests/index.test.ts b/tests/index.test.ts deleted file mode 100644 index 5a1611a..0000000 --- a/tests/index.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { ready as sodiumReady } from "libsodium-wrappers"; -import Phase from "../src/index"; - -// Define a mock implementation of fetchAppKeyShare -jest.mock("../src/utils/wrappedShare", () => ({ - fetchAppKeyShare: jest.fn(async () => { - const unwrappedKey = - "e35ae9560207c90fa3dd68a8715e13a1ef988bffa284db73f04328df17f37cfe"; - return Promise.resolve(unwrappedKey); - }), -})); - -describe("Phase", () => { - beforeAll(async () => { - await sodiumReady; - }); - - describe("Initialization", () => { - const APP_ID = - "phApp:v1:cd2d579490fd794f1640590220de86a3676fa7979d419056bc631741b320b701"; - const APP_SECRET = - "pss:v1:a7a0822aa4a4e4d37919009264200ba6ab978d92c8b4f7db5ae9ce0dfaf604fe:801605dfb89822ff52957abe39949bcfc44b9058ad81de58dd54fb0b110037b4b2bbde5a1143d31bbb3895f72e4ee52f5bd:625d395987f52c37022063eaf9b6260cad9ca03c99609213f899cae7f1bb04e7"; - - test("Should throw an error with an invalid appId", () => { - const invalidAppId = - "phApp:version:cd2d579490fd794f1640590220de86a3676fa7979d419056bc631741b320b701"; - expect( - () => new Phase(invalidAppId as any, APP_SECRET as any) - ).toThrowError("Invalid Phase appID"); - }); - - test("Should throw an error with an invalid appSecret", () => { - const invalidAppSecret = - "pss:v1:00000000000000000000000000000000:00000000000000000000000000000000:00000000000000000000000000000000"; - expect(() => new Phase(APP_ID, invalidAppSecret as any)).toThrowError( - "Invalid Phase AppSecret" - ); - }); - }); - - describe("Encryption", () => { - const APP_ID = - "phApp:v1:cd2d579490fd794f1640590220de86a3676fa7979d419056bc631741b320b701"; - const APP_SECRET = - "pss:v1:a7a0822aa4a4e4d37919009264200ba6ab978d92c8b4f7db5ae9ce0dfaf604fe:801605dfb89822ff52957abe39949bcfc44b9058ad81de58dd54fb0b110037b4b2bbde5a1143d31bbb3895f72e4ee52f5bd:625d395987f52c37022063eaf9b6260cad9ca03c99609213f899cae7f1bb04e7"; - - test("Check if Phase encrypt returns a valid ph", async () => { - const phase = new Phase(APP_ID, APP_SECRET); - const plaintext = "Signal"; - const tag = "Phase Tag"; - const PH_VERSION = "v1"; - const ciphertext = await phase.encrypt(plaintext, tag); - expect(ciphertext).toBeDefined(); - const segments = (ciphertext as string).split(":"); - expect(segments.length).toBe(5); - expect(segments[0]).toBe("ph"); - expect(segments[1]).toBe(PH_VERSION); - expect(segments[4]).toBe(tag); - // Check if the one-time public key and ciphertext are valid hex strings - expect(segments[2]).toMatch(/^[0-9a-f]+$/); - expect(segments[3]).toMatch( - /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ - ); - }); - - test("Check if Phase encrypt always produces ciphertexts (ph:*) of the same length for the same plaintext", async () => { - const phase = new Phase(APP_ID, APP_SECRET); - const data = "hello world"; - const numOfTrials = 10; - const ciphertextLengths = new Set(); - - for (let i = 0; i < numOfTrials; i++) { - const ciphertext = await phase.encrypt(data); - ciphertextLengths.add((ciphertext as string).length); - } - - expect(ciphertextLengths.size).toBe(1); - }); - }); - - describe("Decryption", () => { - test("Check if Phase decrypt returns the correct plaintext", async () => { - const APP_ID = - "phApp:v1:e0e50cb9a1953c610126b4092093b1beca51d08d91fc3d9f8d90482a32853215"; - const APP_SECRET = - "pss:v1:d261abecb6708c18bebdb8b2748ee574e2b0bdeaf19b081a5f10006cc83d48d0:d146c8c6d326a7842ff9b2da0da455b3f7f568a70808e2eb0cfc5143d4fe170f:59e413612e06d75d251e3416361d0743345a9c9eda1cbcf2b1ef16e3077c011c"; - - const phase = new Phase(APP_ID, APP_SECRET); - const data = "Signal"; - const ciphertext = await phase.encrypt(data); - expect(ciphertext).toBeDefined(); - - const plaintext = await phase.decrypt(ciphertext); - expect(plaintext).toBeDefined(); - expect(plaintext).toBe(data); - }); - - test("Check if Phase decrypt rejects the promise when the app secret is incorrect", async () => { - const APP_ID = - "phApp:v1:e0e50cb9a1953c610126b4092093b1beca51d08d91fc3d9f8d90482a32853215"; - const APP_SECRET_INCORRECT = - "pss:v1:d251abecb6708c18bebdb8b2748ee574e2b0bdeaf19b081a5f10006cc83d48d0:d146c8c6d326a7842ff9b2da0da455b3f7f568a70808e2eb0cfc5143d4fe170d:59e413612e06d75d251e3416361d0743345a9c9eda1cbcf2b1ef16e3077c012d"; - - const phase = new Phase(APP_ID, APP_SECRET_INCORRECT); - const data = "Signal"; - const ciphertext = await phase.encrypt(data); - expect(() => phase.decrypt(ciphertext)).rejects.toBeDefined(); - }); - }); -}); diff --git a/tests/sdk/init.test.ts b/tests/sdk/init.test.ts new file mode 100644 index 0000000..1208327 --- /dev/null +++ b/tests/sdk/init.test.ts @@ -0,0 +1,433 @@ +import axios from "axios"; +import Phase from "../../src"; + +jest.mock("axios"); +// jest.mock("../src/utils/crypto", () => ({ +// reconstructPrivateKey: jest.fn(), +// unwrapEnvKeys: jest.fn(), +// })); + +describe("Phase SDK - init() failure modes", () => { + const validUserToken = + "pss_user:v1:6759ecdb7307cb2a1994fe397bd690725cdfabbda952e4cc568fc5e3e0286a3d:b8f2214246b79a20dfcd62870e7e6a48343f9c7fcdfb7d664175ac11a76bce10:451f1dce52290ee0a4d8f4b8f9fe0a946447e288ff1b10b494fdd5fe28ed4ab8:959c5fe47a3b35cd8278890ce208a318d5411aa1cf06d5fc2cbe8af41904544e"; + const validServiceToken = + "pss_service:v2:048c9daa773b2d9bb21a1dda69a56f2b895401fded26889c7063c72224a61f65:fc065f7e5e1093292b20d0c917968cbe3badfb2e82bf1caa70649b72d0017905:1bb35376aacb277fd2792d781dbff5d0e9f7547da3544f4d394ff027d91436d5:4a04b7a5100c67cbf6376f8dae1ca936ca17bcbc119a4a84f8e3727696460925"; + const mockHost = "http://localhost"; + + const mockResponse = { + wrapped_key_share: + "e2101a6796a2960571cfced645f5c721ed5597d257f91c3fe891ddb02231ba4817f7123d2ec2f1d353ba1f178424ead02b624d1862e3aa55498a47a02c4a07a446852791c89c4b7640f20471c5ede0d97b276a68158847c348ba90ee4f302a603812c6c1df7cfa6f", + user_id: "59c68014-d1b9-4896-8a88-8b1088165dde", + offline_enabled: false, + organisation: { + id: "79a36b56-b0a1-4fa2-bd00-6c031868fb10", + name: "test", + }, + apps: [ + { + id: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + name: "test", + encryption: "E2E", + environment_keys: [ + { + id: "8b226ced-d51c-4954-b8cb-de86d5c0c2eb", + environment: { + id: "52db76e0-16a0-4b39-b2a9-bb518cd1b6a6", + name: "Development", + env_type: "DEV", + }, + paths: null, + identity_key: + "2d1eb10b8406cc4a2ef0287053cd21da408a24fbfb65d8002f56132fbe46cc20", + wrapped_seed: + "ph:v1:59c2f1b02713effee170712a105a751863339d82554b9a0a7d966f6a6111c63d:+uHF+qN1SRRo7l3wga4WdjJpclLXQjjLP8L+FOaYc9RfZPjb5CSYaYon8SZW1fSII9YNiUFTXKnFo22bsLBIwaBDK7b/xBdcFREb5G2RfAKiBb6j1Es1ux3f3ZFz/bul/6msAuQKK6E=", + wrapped_salt: + "ph:v1:6ddd5db0fca06cf54bf86ec5de403d4c11871e4124d84b0983e0ab0ba865e728:nJ2rYnvoWzsG2CPXbkLyWTANh8LlNn222axOwGjf+UE8hKBjocT7XigS0zPNRsu8+V/YEKVngEOV2dupKqGtlsgkW3Mbd17T2YMkExx/x/TJgZXKoNamc2vuLYUtTlamhB8XHfsa41E=", + created_at: "2025-02-01T16:51:25.941911Z", + updated_at: "2025-02-01T16:51:25.941916Z", + deleted_at: null, + user: "59c68014-d1b9-4896-8a88-8b1088165dde", + service_account: null, + }, + { + id: "2c136678-cb87-4ed7-aa43-f1dcd7c5ba9f", + environment: { + id: "c290c2ea-7611-49e8-b7fd-c1046bccaa61", + name: "Staging", + env_type: "STAGING", + }, + paths: null, + identity_key: + "d80ad5a145b4c5477473965c5b832e8856a51b612baea1b8da2028616217540a", + wrapped_seed: + "ph:v1:30b919477e01468a5477929888e91fd62a3a71382ef01747e1c3f2bc799dd83c:MGyxo5DT+6dcGgZZoD9rOgch/3KLHA9wfYBqnjnGKxHv6/l9FtL7ZHOt8TRAExluPr+ebjMQuqKMKn6c5Z9lDZSbREM4+dQb9hq6DAuejpTlEEqYhIZhUqritLiiHK+zZW+Xw0Xy/Gs=", + wrapped_salt: + "ph:v1:d62677ecf0713e99242af3330d9807d2d13f0c44c1f69314538eb8fba66f9305:3oX1QNtZkPv8f9ViyeBiBDMT1tZhOS2k8tvoG1WALYG0t5sDSBtmXjXKiYZw9cm8hHvI3cjDT20XrhG3/79QYVeDbFA1WkLyg8cFickXjiucHahhmKXyEJm1qrLcksYsBho6moLOWkM=", + created_at: "2025-02-01T16:51:25.956031Z", + updated_at: "2025-02-01T16:51:25.956036Z", + deleted_at: null, + user: "59c68014-d1b9-4896-8a88-8b1088165dde", + service_account: null, + }, + { + id: "3dd91343-46de-44d9-be47-a8a4ca471458", + environment: { + id: "c55ed69f-edd6-420a-8156-8b8daa86e713", + name: "Production", + env_type: "PROD", + }, + paths: null, + identity_key: + "700e79e28535aac9a8fe960744ca8c9468dc10cca9f6ff31447beb1f1b51ee4d", + wrapped_seed: + "ph:v1:f750279a24a074bb713313bdf8db256c3ae326f24b1e1da52e3740fd3f0dbc11:Rdmvc/O6YMg9LEC0UXtcBN9i9W1ELh77BlB/sZc1TMoUWxzEQF0ONt/ILUCOBTmAcVi2xWOtJfR6XH0wT2jygZm/VTmBSyVopxzQzSXaNWmgeXjgm8+8xEbZx8pkJPQMSmIXTBVd2aA=", + wrapped_salt: + "ph:v1:3f44a6cadb8341eba84a15dfb9c47880db5d48fa862d3718b0d34efcb5290d1f:Y5htuo04TV73RxzqpXtO3ooI9qeEpp4wbVS9PeCWEKNFJUw5NGX7NovblQojcGKcit0Ku8JMLMEn4P6/Gfn54164Lu8PEs3q0Z6h5pCsTYu+hKuIuD83onx5qc9ARgXI7ga7HWyB2Oc=", + created_at: "2025-02-01T16:51:25.966753Z", + updated_at: "2025-02-01T16:51:25.966759Z", + deleted_at: null, + user: "59c68014-d1b9-4896-8a88-8b1088165dde", + service_account: null, + }, + ], + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: mockResponse, + }); + }); + + it("should throw an error if no token is provided", async () => { + expect(() => new Phase("", mockHost)).toThrow( + "Invalid token format. Token does not match the expected pattern." + ); + }); + + it("should throw an error if token format is invalid", async () => { + const invalidToken = "invalid-token-format"; + expect(() => new Phase(invalidToken, mockHost)).toThrow( + "Invalid token format" + ); + }); + + it("should fail if the API returns a 400 Bad Request", async () => { + (axios.get as jest.Mock).mockRejectedValue({ + response: { status: 400, data: "Bad Request" }, + }); + + const phase = new Phase(validUserToken, mockHost); + + await expect(phase.init()).rejects.toThrow("Failed to initialize session"); + }); + + it("should fail if the API returns a 500 Internal Server Error", async () => { + (axios.get as jest.Mock).mockRejectedValue({ + response: { status: 500, data: "Internal Server Error" }, + }); + + const phase = new Phase(validUserToken, mockHost); + + await expect(phase.init()).rejects.toThrow("Failed to initialize session"); + }); + + it("should fail with a network error", async () => { + (axios.get as jest.Mock).mockRejectedValue(new Error("Network error")); + + const phase = new Phase(validUserToken, mockHost); + + await expect(phase.init()).rejects.toThrow("Failed to initialize session"); + }); +}); + +describe("Phase SDK - init() with valid user token", () => { + const validUserToken = + "pss_user:v1:6759ecdb7307cb2a1994fe397bd690725cdfabbda952e4cc568fc5e3e0286a3d:b8f2214246b79a20dfcd62870e7e6a48343f9c7fcdfb7d664175ac11a76bce10:451f1dce52290ee0a4d8f4b8f9fe0a946447e288ff1b10b494fdd5fe28ed4ab8:959c5fe47a3b35cd8278890ce208a318d5411aa1cf06d5fc2cbe8af41904544e"; + const validServiceToken = + "pss_service:v2:048c9daa773b2d9bb21a1dda69a56f2b895401fded26889c7063c72224a61f65:fc065f7e5e1093292b20d0c917968cbe3badfb2e82bf1caa70649b72d0017905:1bb35376aacb277fd2792d781dbff5d0e9f7547da3544f4d394ff027d91436d5:4a04b7a5100c67cbf6376f8dae1ca936ca17bcbc119a4a84f8e3727696460925"; + const mockHost = "http://localhost"; + + const mockResponse = { + wrapped_key_share: + "e2101a6796a2960571cfced645f5c721ed5597d257f91c3fe891ddb02231ba4817f7123d2ec2f1d353ba1f178424ead02b624d1862e3aa55498a47a02c4a07a446852791c89c4b7640f20471c5ede0d97b276a68158847c348ba90ee4f302a603812c6c1df7cfa6f", + user_id: "59c68014-d1b9-4896-8a88-8b1088165dde", + offline_enabled: false, + organisation: { + id: "79a36b56-b0a1-4fa2-bd00-6c031868fb10", + name: "test", + }, + apps: [ + { + id: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + name: "test", + encryption: "E2E", + environment_keys: [ + { + id: "8b226ced-d51c-4954-b8cb-de86d5c0c2eb", + environment: { + id: "52db76e0-16a0-4b39-b2a9-bb518cd1b6a6", + name: "Development", + env_type: "DEV", + }, + paths: null, + identity_key: + "2d1eb10b8406cc4a2ef0287053cd21da408a24fbfb65d8002f56132fbe46cc20", + wrapped_seed: + "ph:v1:59c2f1b02713effee170712a105a751863339d82554b9a0a7d966f6a6111c63d:+uHF+qN1SRRo7l3wga4WdjJpclLXQjjLP8L+FOaYc9RfZPjb5CSYaYon8SZW1fSII9YNiUFTXKnFo22bsLBIwaBDK7b/xBdcFREb5G2RfAKiBb6j1Es1ux3f3ZFz/bul/6msAuQKK6E=", + wrapped_salt: + "ph:v1:6ddd5db0fca06cf54bf86ec5de403d4c11871e4124d84b0983e0ab0ba865e728:nJ2rYnvoWzsG2CPXbkLyWTANh8LlNn222axOwGjf+UE8hKBjocT7XigS0zPNRsu8+V/YEKVngEOV2dupKqGtlsgkW3Mbd17T2YMkExx/x/TJgZXKoNamc2vuLYUtTlamhB8XHfsa41E=", + created_at: "2025-02-01T16:51:25.941911Z", + updated_at: "2025-02-01T16:51:25.941916Z", + deleted_at: null, + user: "59c68014-d1b9-4896-8a88-8b1088165dde", + service_account: null, + }, + { + id: "2c136678-cb87-4ed7-aa43-f1dcd7c5ba9f", + environment: { + id: "c290c2ea-7611-49e8-b7fd-c1046bccaa61", + name: "Staging", + env_type: "STAGING", + }, + paths: null, + identity_key: + "d80ad5a145b4c5477473965c5b832e8856a51b612baea1b8da2028616217540a", + wrapped_seed: + "ph:v1:30b919477e01468a5477929888e91fd62a3a71382ef01747e1c3f2bc799dd83c:MGyxo5DT+6dcGgZZoD9rOgch/3KLHA9wfYBqnjnGKxHv6/l9FtL7ZHOt8TRAExluPr+ebjMQuqKMKn6c5Z9lDZSbREM4+dQb9hq6DAuejpTlEEqYhIZhUqritLiiHK+zZW+Xw0Xy/Gs=", + wrapped_salt: + "ph:v1:d62677ecf0713e99242af3330d9807d2d13f0c44c1f69314538eb8fba66f9305:3oX1QNtZkPv8f9ViyeBiBDMT1tZhOS2k8tvoG1WALYG0t5sDSBtmXjXKiYZw9cm8hHvI3cjDT20XrhG3/79QYVeDbFA1WkLyg8cFickXjiucHahhmKXyEJm1qrLcksYsBho6moLOWkM=", + created_at: "2025-02-01T16:51:25.956031Z", + updated_at: "2025-02-01T16:51:25.956036Z", + deleted_at: null, + user: "59c68014-d1b9-4896-8a88-8b1088165dde", + service_account: null, + }, + { + id: "3dd91343-46de-44d9-be47-a8a4ca471458", + environment: { + id: "c55ed69f-edd6-420a-8156-8b8daa86e713", + name: "Production", + env_type: "PROD", + }, + paths: null, + identity_key: + "700e79e28535aac9a8fe960744ca8c9468dc10cca9f6ff31447beb1f1b51ee4d", + wrapped_seed: + "ph:v1:f750279a24a074bb713313bdf8db256c3ae326f24b1e1da52e3740fd3f0dbc11:Rdmvc/O6YMg9LEC0UXtcBN9i9W1ELh77BlB/sZc1TMoUWxzEQF0ONt/ILUCOBTmAcVi2xWOtJfR6XH0wT2jygZm/VTmBSyVopxzQzSXaNWmgeXjgm8+8xEbZx8pkJPQMSmIXTBVd2aA=", + wrapped_salt: + "ph:v1:3f44a6cadb8341eba84a15dfb9c47880db5d48fa862d3718b0d34efcb5290d1f:Y5htuo04TV73RxzqpXtO3ooI9qeEpp4wbVS9PeCWEKNFJUw5NGX7NovblQojcGKcit0Ku8JMLMEn4P6/Gfn54164Lu8PEs3q0Z6h5pCsTYu+hKuIuD83onx5qc9ARgXI7ga7HWyB2Oc=", + created_at: "2025-02-01T16:51:25.966753Z", + updated_at: "2025-02-01T16:51:25.966759Z", + deleted_at: null, + user: "59c68014-d1b9-4896-8a88-8b1088165dde", + service_account: null, + }, + ], + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: mockResponse, + }); + }); + + it("should correctly initialize and set class properties with a user token", async () => { + const phase = new Phase(validUserToken, mockHost); + await phase.init(); + + expect(phase.token).toBe(validUserToken); + expect(phase.host).toBe(mockHost); + expect(phase.tokenType).toBe("User"); // Assuming the token is a user token + expect(phase.version).toBe("v1"); + expect(phase.bearerToken).toBe(validUserToken.split(":")[2]); // Extracted from the token + + expect(phase.keypair).toHaveProperty("publicKey"); + expect(phase.keypair).toHaveProperty("privateKey"); + expect(phase.keypair.privateKey).toBeDefined(); + + expect(phase.apps.length).toBe(mockResponse.apps.length); + for (let i = 0; i < phase.apps.length; i++) { + expect(phase.apps[i].id).toBe(mockResponse.apps[i].id); + expect(phase.apps[i].name).toBe(mockResponse.apps[i].name); + + expect(phase.apps[i].environments.length).toBe( + mockResponse.apps[i].environment_keys.length + ); + + for (let j = 0; j < phase.apps[i].environments.length; j++) { + expect(phase.apps[i].environments[j].keypair.publicKey).toBeDefined(); + expect(phase.apps[i].environments[j].keypair.privateKey).toBeDefined(); + expect(phase.apps[i].environments[j].salt).toBeDefined(); + } + } + }); + + it("should reconstruct the private key and decrypt environment keys correctly with a user token", async () => { + const phase = new Phase(validUserToken, mockHost); + await phase.init(); + + // Ensure reconstructPrivateKey was called with real data + expect(phase.keypair.privateKey).toBeDefined(); + expect(phase.keypair.publicKey).toBe(validUserToken.split(":")[3]); + + // Verify each environment key was unwrapped correctly + for (const app of phase.apps) { + for (const env of app.environments) { + expect(env.keypair.publicKey).toBeDefined(); + expect(env.keypair.privateKey).toBeDefined(); + expect(env.salt).toBeDefined(); + } + } + }); +}); + +describe("Phase SDK - init() with valid service token", () => { + const validServiceToken = + "pss_service:v2:048c9daa773b2d9bb21a1dda69a56f2b895401fded26889c7063c72224a61f65:fc065f7e5e1093292b20d0c917968cbe3badfb2e82bf1caa70649b72d0017905:1bb35376aacb277fd2792d781dbff5d0e9f7547da3544f4d394ff027d91436d5:4a04b7a5100c67cbf6376f8dae1ca936ca17bcbc119a4a84f8e3727696460925"; + const mockHost = "http://localhost"; + + const mockResponse = { + wrapped_key_share: + "2c0ffe4625b95a8ebaa9a5bd3a2c908de33c10dd4768313226854c4e484aa6e5a2dc5e0c3b740c4f8302764d40aa64c1e4dcad7bdc9d2f6b8dacdaabab661157b62b647b83e11cac6c750bd2e4dd35a4646c26d61926bd967e1546a5ec59b8bbe6b5c5aa82e01629", + user_id: "59c68014-d1b9-4896-8a88-8b1088165dde", + offline_enabled: false, + organisation: { + id: "79a36b56-b0a1-4fa2-bd00-6c031868fb10", + name: "test", + }, + apps: [ + { + id: "3b7443aa-3a7c-4791-849a-42aafc9cbe66", + name: "test", + encryption: "E2E", + environment_keys: [ + { + id: "12a6a8ab-6a4e-4c01-bb76-c1987d1c6824", + environment: { + id: "52db76e0-16a0-4b39-b2a9-bb518cd1b6a6", + name: "Development", + env_type: "DEV", + }, + paths: null, + identity_key: + "2d1eb10b8406cc4a2ef0287053cd21da408a24fbfb65d8002f56132fbe46cc20", + wrapped_seed: + "ph:v1:7a5e6c91afc57338cee5dcd683adcac08f2be2c958a3c0d012ee60a340a65a00:SKD/QatCqjcxLWs335JyBxbSkFxeXRYLuOl5LM9/MLVR/7F5/kBsrNZFdQIyciv/avS2umpqpdbHsPzDluMDnVkHIqd//DT96QoqIqns0Iq2A+HVvW4h/xVQDCZ3c9BeSaY6WDcG/eI=", + wrapped_salt: + "ph:v1:f35d14371af1a6445d7ed1a4ee635124f5e64f136a3531b8ea58a80d555d6079:HMXa4x92jbs257cy5Y7M22bMc1PK5nyqWn3y+x0dLBAv2ZV1j4fNKx++7pEknYZfSg6wFkCuAbOMaYJsbwY3JIa+wbtvu4Wt9QG8qx77cPazMbLkxTfDuQe28Gl7bho5t0484Xp85fs=", + created_at: "2025-02-01T16:52:28.852849Z", + updated_at: "2025-02-01T16:52:28.852858Z", + deleted_at: null, + user: null, + service_account: "c96b80c1-d43c-4b0a-98fb-b981b731ae63", + }, + { + id: "96c3b71d-76e8-4298-8fc9-053fc9b1368b", + environment: { + id: "c290c2ea-7611-49e8-b7fd-c1046bccaa61", + name: "Staging", + env_type: "STAGING", + }, + paths: null, + identity_key: + "d80ad5a145b4c5477473965c5b832e8856a51b612baea1b8da2028616217540a", + wrapped_seed: + "ph:v1:0bedacb6cb2dd95775710516535ae43ab1503dc59414999ca5cf3649f5ff4d13:hMu1mbHuQG6GNy0IaVK2pOXDUUVE+cKQ5Bo3+C69yMh1nFjmLBG0nh7g2gZoIEvFayN/S5thARgZ9YWs0E8bsXHXkuNTTkRScKkrj0ZU5qvoD843PzHjVLcUSdNipoNik8vkZSuIq2U=", + wrapped_salt: + "ph:v1:1d8c5eca943096d87957ef824f14d748250d77cb87513394690c500ff9de780a:cA2nbqAOMYI7aChKymIAgBefWUSnPi8srNW/HhhjU62RxdgdBbCydV18C/nsPYLadsvw6pEvySNZJ3JfrhCFXqYCYh5APeg4u9s3W5bBsgNV3glM3I9uR0uasbHmuLNLOPb3xyzhB6o=", + created_at: "2025-02-01T16:52:28.856257Z", + updated_at: "2025-02-01T16:52:28.856262Z", + deleted_at: null, + user: null, + service_account: "c96b80c1-d43c-4b0a-98fb-b981b731ae63", + }, + { + id: "4c8d94bc-2c9f-4120-a3d2-5647f9f2ef5f", + environment: { + id: "c55ed69f-edd6-420a-8156-8b8daa86e713", + name: "Production", + env_type: "PROD", + }, + paths: null, + identity_key: + "700e79e28535aac9a8fe960744ca8c9468dc10cca9f6ff31447beb1f1b51ee4d", + wrapped_seed: + "ph:v1:827cd1026673911409d0f97cbd011db058c6dd73388a62389ac547493c5c3926:sZu9V7SeLaK/9QHDYQh5T717uqsTXLCSSamvysvVU9HC21A0iHrVGdZsNIib8QIeZz9Cadr2TTiOdks74DTHMWkAca/JObPgWZKz6k02/6Jt6ZeNjWEqcemjSb4xwPQ+9/sjJWQC4kY=", + wrapped_salt: + "ph:v1:418a906994764da27f93de6e40310589e8e2201a29dce6bec196186b23e4ff04:O/H2xT/1mqDlZ9oyc9cKBXHmdvEjCqf9Q/elLHrnV8rlqxZzjPoIfGBg72turuZS1b22Mk551c42dVP0ReJqcw67qQSP4GilMJM2Q5BaAglkxUJreXff66RCMTr7/RNiZohhI6CaipE=", + created_at: "2025-02-01T16:52:28.858876Z", + updated_at: "2025-02-01T16:52:28.858882Z", + deleted_at: null, + user: null, + service_account: "c96b80c1-d43c-4b0a-98fb-b981b731ae63", + }, + ], + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: mockResponse, + }); + }); + + it("should correctly initialize and set class properties with a service token", async () => { + const phase = new Phase(validServiceToken, mockHost); + await phase.init(); + + expect(phase.token).toBe(validServiceToken); + expect(phase.host).toBe(mockHost); + expect(phase.tokenType).toBe("ServiceAccount"); + expect(phase.version).toBe("v2"); + expect(phase.bearerToken).toBe(validServiceToken.split(":")[2]); // Extracted from the token + + expect(phase.keypair).toHaveProperty("publicKey"); + expect(phase.keypair).toHaveProperty("privateKey"); + expect(phase.keypair.privateKey).toBeDefined(); + + expect(phase.apps.length).toBe(mockResponse.apps.length); + for (let i = 0; i < phase.apps.length; i++) { + expect(phase.apps[i].id).toBe(mockResponse.apps[i].id); + expect(phase.apps[i].name).toBe(mockResponse.apps[i].name); + + expect(phase.apps[i].environments.length).toBe( + mockResponse.apps[i].environment_keys.length + ); + + for (let j = 0; j < phase.apps[i].environments.length; j++) { + expect(phase.apps[i].environments[j].keypair.publicKey).toBeDefined(); + expect(phase.apps[i].environments[j].keypair.privateKey).toBeDefined(); + expect(phase.apps[i].environments[j].salt).toBeDefined(); + } + } + }); + + it("should reconstruct the private key and decrypt environment keys correctly with a service token", async () => { + const phase = new Phase(validServiceToken, mockHost); + await phase.init(); + + // Ensure reconstructPrivateKey was called with real data + expect(phase.keypair.privateKey).toBeDefined(); + expect(phase.keypair.publicKey).toBe(validServiceToken.split(":")[3]); + + // Verify each environment key was unwrapped correctly + for (const app of phase.apps) { + for (const env of app.environments) { + expect(env.keypair.publicKey).toBeDefined(); + expect(env.keypair.privateKey).toBeDefined(); + expect(env.salt).toBeDefined(); + } + } + }); +}); diff --git a/tests/utils/crypto/general.test.ts b/tests/utils/crypto/general.test.ts new file mode 100644 index 0000000..d2b58ae --- /dev/null +++ b/tests/utils/crypto/general.test.ts @@ -0,0 +1,198 @@ +import _sodium from "libsodium-wrappers"; +import { + encryptRaw, + decryptRaw, + encryptString, + decryptString, + randomKeyPair, + clientSessionKeys, + serverSessionKeys, + encryptAsymmetric, + decryptAsymmetric, + VERSION, + digest, +} from "../../../src/utils/crypto"; + +describe("Crypto Utils Tests", () => { + test("randomKeyPair generates keys of correct length", async () => { + const keyPair = await randomKeyPair(); + expect(keyPair.publicKey.length).toBe(32); + expect(keyPair.privateKey.length).toBe(32); + }); + + test("clientSessionKeys generates keys of correct length", async () => { + const clientKeyPair = await randomKeyPair(); + const serverKeyPair = await randomKeyPair(); + const clientKeys = await clientSessionKeys( + clientKeyPair, + serverKeyPair.publicKey + ); + expect(clientKeys.sharedRx.length).toBe(32); + expect(clientKeys.sharedTx.length).toBe(32); + }); + + test("serverSessionKeys generates keys of correct length", async () => { + const serverKeyPair = await randomKeyPair(); + const clientKeyPair = await randomKeyPair(); + const serverKeys = await serverSessionKeys( + serverKeyPair, + clientKeyPair.publicKey + ); + expect(serverKeys.sharedRx.length).toBe(32); + expect(serverKeys.sharedTx.length).toBe(32); + }); +}); + +describe("Asymmetric Encryption and Decryption Tests", () => { + test("encryptAsymmetric and decryptAsymmetric return original plaintext", async () => { + const testPlaintext = + "Saigon, I'm still only in Saigon. Every time I think I'm gonna wake up back in the jungle.."; + const keyPair = await randomKeyPair(); + const publicKeyHex = Buffer.from(keyPair.publicKey).toString("hex"); + const privateKeyHex = Buffer.from(keyPair.privateKey).toString("hex"); + + const encryptedData = await encryptAsymmetric(testPlaintext, publicKeyHex); + const decryptedData = await decryptAsymmetric( + encryptedData, + privateKeyHex, + publicKeyHex + ); + + // Regex to match the encrypted data pattern + const pattern = new RegExp(`ph:v${VERSION}:[0-9a-fA-F]{64}:.+`); + expect(encryptedData).toMatch(pattern); + expect(decryptedData).toBe(testPlaintext); + }); +}); + +describe("BLAKE2b Digest Tests", () => { + test("digest produces correct length hash", async () => { + const inputStr = "test string"; + const salt = "salt"; + const result = await digest(inputStr, salt); + expect(result.length).toBe(64); + }); + + test("digest is consistent for same input and salt", async () => { + const inputStr = "consistent input"; + const salt = "consistent salt"; + const hash1 = await digest(inputStr, salt); + const hash2 = await digest(inputStr, salt); + expect(hash1).toBe(hash2); + }); + + test("digest is unique with different inputs", async () => { + const salt = "salt"; + const hash1 = await digest("input1", salt); + const hash2 = await digest("input2", salt); + expect(hash1).not.toBe(hash2); + }); + + test("digest is unique with different salts", async () => { + const inputStr = "input"; + const hash1 = await digest(inputStr, "salt1"); + const hash2 = await digest(inputStr, "salt2"); + expect(hash1).not.toBe(hash2); + }); + + const knownHashes = [ + { + inputStr: "hello", + salt: "world", + expectedHash: + "38010cfe3a8e684cb17e6d049525e71d4e9dc3be173fc05bf5c5ca1c7e7c25e7", + }, + { + inputStr: "another test", + salt: "another salt", + expectedHash: + "5afad949edcfb22bd24baeed4e75b0aeca41731b8dff78f989a5a4c0564f211f", + }, + ]; + + knownHashes.forEach(({ inputStr, salt, expectedHash }) => { + test(`digest produces known hash for input "${inputStr}" and salt "${salt}"`, async () => { + const result = await digest(inputStr, salt); + expect(result).toBe(expectedHash); + }); + }); +}); + +describe("XChaCha20-Poly1305 Encrypt and Decrypt Tests", () => { + test("encryptRaw returns ciphertext with appended nonce", async () => { + const plaintext = "test message"; + const key = new Uint8Array(32); // 256-bit key + const result = await encryptRaw(plaintext, key); + + // Nonce length for XChaCha20-Poly1305 is 24 bytes + const nonceLength = 24; + expect(result.length).toBeGreaterThan(plaintext.length + nonceLength); + }); + + test("encryptRaw produces different ciphertexts for the same input", async () => { + const plaintext = "consistent message"; + const key = new Uint8Array(32); // 256-bit key + const ciphertext1 = await encryptRaw(plaintext, key); + const ciphertext2 = await encryptRaw(plaintext, key); + + expect(ciphertext1).not.toEqual(ciphertext2); // Different due to random nonce + }); + + test("nonce is of correct length", async () => { + const plaintext = "message for nonce test"; + const key = new Uint8Array(32); // 256-bit key + const result = await encryptRaw(plaintext, key); + + // Extracting nonce from the end of the ciphertext + const nonceLength = 24; + const nonce = result.slice(-nonceLength); + expect(nonce.length).toBe(nonceLength); + }); + + test("decryptRaw correctly decrypts a message encrypted by encryptRaw", async () => { + const plaintext = "test message"; + const key = new Uint8Array(32); // 256-bit key + const encryptedMessage = await encryptRaw(plaintext, key); + const decryptedMessage = await decryptRaw(encryptedMessage, key); + + expect(new TextDecoder().decode(decryptedMessage)).toBe(plaintext); + }); + + test("decryptRaw with incorrect key fails", async () => { + const plaintext = "test message for wrong key"; + const correctKey = new Uint8Array(32); + const wrongKey = new Uint8Array(32).fill(1); // Incorrect key + const encryptedMessage = await encryptRaw(plaintext, correctKey); + + await expect(decryptRaw(encryptedMessage, wrongKey)).rejects.toThrow(); + }); +}); + +describe("String Encryption and Decryption Tests", () => { + test("encryptString returns base64 string and decryptString retrieves original plaintext", async () => { + const plaintext = "Hello, world!"; + const key = new Uint8Array(32); // 256-bit key + + const encryptedString = await encryptString(plaintext, key); + expect(encryptedString).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); // Regex for base64 format + + const decryptedString = await decryptString(encryptedString, key); + expect(decryptedString).toBe(plaintext); + }); + + test("decryptString with incorrect base64 string fails", async () => { + const incorrectCiphertext = "invalid base64 string"; + const key = new Uint8Array(32); + + await expect(decryptString(incorrectCiphertext, key)).rejects.toThrow(); + }); + + test("decryptString with incorrect key fails", async () => { + const plaintext = "Sensitive data"; + const correctKey = new Uint8Array(32); + const wrongKey = new Uint8Array(32).fill(1); // Incorrect key + + const encryptedString = await encryptString(plaintext, correctKey); + await expect(decryptString(encryptedString, wrongKey)).rejects.toThrow(); + }); +}); diff --git a/tests/utils/crypto/keyHandling.test.ts b/tests/utils/crypto/keyHandling.test.ts new file mode 100644 index 0000000..b68374f --- /dev/null +++ b/tests/utils/crypto/keyHandling.test.ts @@ -0,0 +1,88 @@ +import { + getUserKxPublicKey, + getUserKxPrivateKey, + envKeyring, + unwrapEnvKeys, + reconstructPrivateKey, +} from "../../../src/utils/crypto"; +import * as generalUtils from "../../../src/utils/crypto/general"; +import * as secretSplittingUtils from "../../../src/utils/crypto/secretSplitting"; + +const _sodium = require("libsodium-wrappers"); + +describe("Crypto Utility Functions", () => { + beforeAll(async () => { + await _sodium.ready; + }); + + test("getUserKxPublicKey converts a signing public key correctly", async () => { + const signingKey = _sodium.to_hex(_sodium.crypto_sign_keypair().publicKey); + const kxPublicKey = await getUserKxPublicKey(signingKey); + expect(kxPublicKey).toBeDefined(); + expect(typeof kxPublicKey).toBe("string"); + }); + + test("getUserKxPrivateKey converts a signing private key correctly", async () => { + const signingKey = _sodium.to_hex(_sodium.crypto_sign_keypair().privateKey); + const kxPrivateKey = await getUserKxPrivateKey(signingKey); + expect(kxPrivateKey).toBeDefined(); + expect(typeof kxPrivateKey).toBe("string"); + }); + + test("envKeyring derives a keypair from a given seed", async () => { + const seed = _sodium.to_hex(_sodium.randombytes_buf(32)); + const keyring = await envKeyring(seed); + expect(keyring).toHaveProperty("publicKey"); + expect(keyring).toHaveProperty("privateKey"); + expect(typeof keyring.publicKey).toBe("string"); + expect(typeof keyring.privateKey).toBe("string"); + }); + + test("unwrapEnvKeys decrypts and derives correct keyring", async () => { + const seed = _sodium.to_hex(_sodium.randombytes_buf(32)); + const salt = _sodium.to_hex(_sodium.randombytes_buf(16)); + const keyring = await envKeyring(seed); + + jest + .spyOn(generalUtils, "decryptAsymmetric") + .mockResolvedValueOnce(salt) + .mockResolvedValueOnce(seed); + + const unwrapped = await unwrapEnvKeys( + "wrappedSeed", + "wrappedSalt", + keyring + ); + + expect(unwrapped.seed).toBe(seed); + expect(unwrapped.salt).toBe(salt); + expect(unwrapped.publicKey).toBeDefined(); + expect(unwrapped.privateKey).toBeDefined(); + }); + + test("reconstructPrivateKey correctly reconstructs the private key", async () => { + const privateKey = + "900d0f3f65fa2a95e41c268fe326642667fc1febcc4b864344f783978513555e"; + const token = + "pss_user:v1:6759ecdb7307cb2a1994fe397bd690725cdfabbda952e4cc568fc5e3e0286a3d:b8f2214246b79a20dfcd62870e7e6a48343f9c7fcdfb7d664175ac11a76bce10:451f1dce52290ee0a4d8f4b8f9fe0a946447e288ff1b10b494fdd5fe28ed4ab8:959c5fe47a3b35cd8278890ce208a318d5411aa1cf06d5fc2cbe8af41904544e"; + const keyShare = + "e2101a6796a2960571cfced645f5c721ed5597d257f91c3fe891ddb02231ba4817f7123d2ec2f1d353ba1f178424ead02b624d1862e3aa55498a47a02c4a07a446852791c89c4b7640f20471c5ede0d97b276a68158847c348ba90ee4f302a603812c6c1df7cfa6f"; + + const constructedPrivateKey = await reconstructPrivateKey(keyShare, token); + expect(constructedPrivateKey).toBeDefined(); + expect(constructedPrivateKey).toEqual(privateKey); + }); + + test("unwrapEnvKeys throws an error when decryption fails", async () => { + jest + .spyOn(generalUtils, "decryptAsymmetric") + .mockRejectedValue(new Error("Decryption failed")); + + await expect( + unwrapEnvKeys("invalidSeed", "invalidSalt", { + publicKey: "fake", + privateKey: "fake", + }) + ).rejects.toThrow("Decryption failed"); + }); +}); diff --git a/tests/utils/crypto/secrets.test.ts b/tests/utils/crypto/secrets.test.ts new file mode 100644 index 0000000..50759de --- /dev/null +++ b/tests/utils/crypto/secrets.test.ts @@ -0,0 +1,130 @@ +import _sodium from "libsodium-wrappers"; +import { Secret } from "../../../src/types"; +import { + randomKeyPair, + encryptEnvSecrets, + decryptEnvSecrets, + digest, +} from "../../../src/utils/crypto"; + +describe("Environment Secrets Encryption", () => { + let keyPair: { publicKey: string; privateKey: string }; + let envSalt: string; + + beforeAll(async () => { + await _sodium.ready; + const sodium = _sodium; + + const generatedKeyPair = await randomKeyPair(); + keyPair = { + publicKey: sodium.to_hex(generatedKeyPair.publicKey), + privateKey: sodium.to_hex(generatedKeyPair.privateKey), + }; + envSalt = "random_salt_value"; + }); + + test("Encrypt and decrypt a single secret", async () => { + const secret: Partial = { + key: "SECRET_KEY", + value: "myvalue", + comment: "comment", + override: { value: "override_value", isActive: true }, + }; + + const encryptedSecrets = await encryptEnvSecrets( + [secret], + keyPair, + envSalt + ); + expect(encryptedSecrets[0].key).not.toEqual(secret.key); + expect(encryptedSecrets[0].value).not.toEqual(secret.value); + expect(encryptedSecrets[0].keyDigest).toBeDefined(); + + const decryptedSecrets = await decryptEnvSecrets( + encryptedSecrets as Secret[], + keyPair + ); + + expect(decryptedSecrets[0].key).toEqual(secret.key); + expect(decryptedSecrets[0].value).toEqual(secret.value); + }); + + test("Ensure key digest is correct", async () => { + const secret: Partial = { key: "TEST_KEY" }; + + const encryptedSecrets = await encryptEnvSecrets( + [secret], + keyPair, + envSalt + ); + const expectedDigest = await digest(secret.key!, envSalt); + + expect(encryptedSecrets[0].keyDigest).toEqual(expectedDigest); + }); + + test("Encrypt and decrypt multiple secrets", async () => { + const secrets: Partial[] = [ + { key: "API_KEY", value: "12345" }, + { key: "DATABASE_URL", value: "postgres://user:pass@localhost" }, + { key: "EMPTY_VALUE", value: "" }, + ]; + + const encryptedSecrets = await encryptEnvSecrets(secrets, keyPair, envSalt); + expect(encryptedSecrets.length).toBe(secrets.length); + + const decryptedSecrets = await decryptEnvSecrets( + encryptedSecrets as Secret[], + keyPair + ); + + decryptedSecrets.forEach((dec, i) => { + expect(dec.key).toEqual(secrets[i].key); + expect(dec.value).toEqual(secrets[i].value); + }); + }); + + test("Decrypt fails with incorrect private key", async () => { + const secret: Partial = { key: "SENSITIVE" }; + + const encryptedSecrets = await encryptEnvSecrets( + [secret], + keyPair, + envSalt + ); + + const wrongKeyPair = await randomKeyPair(); + const wrongPrivateKey = _sodium.to_hex(wrongKeyPair.privateKey); + + await expect( + decryptEnvSecrets(encryptedSecrets as Secret[], { + publicKey: keyPair.publicKey, + privateKey: wrongPrivateKey, + }) + ).rejects.toThrow(); + }); + + test("Tampered encrypted secret should not decrypt correctly", async () => { + const secret: Partial = { key: "TAMPERED" }; + + const encryptedSecrets = await encryptEnvSecrets( + [secret], + keyPair, + envSalt + ); + + // Modify the encrypted key (tamper with ciphertext) + encryptedSecrets[0].key = encryptedSecrets[0].key!.slice(0, -1) + "X"; + + await expect( + decryptEnvSecrets(encryptedSecrets as Secret[], keyPair) + ).rejects.toThrow(); + }); + + test("Handles empty array input", async () => { + const encryptedSecrets = await encryptEnvSecrets([], keyPair, envSalt); + expect(encryptedSecrets).toEqual([]); + + const decryptedSecrets = await decryptEnvSecrets([], keyPair); + expect(decryptedSecrets).toEqual([]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f834dd3..ef3c9c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2018", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ diff --git a/version.ts b/version.ts index 5709423..bf61028 100644 --- a/version.ts +++ b/version.ts @@ -1 +1 @@ -export const LIB_VERSION = "2.1.0"; +export const LIB_VERSION = "3.0.0";