diff --git a/src/handlers/generate-erc20-permit.ts b/src/handlers/generate-erc20-permit.ts index 873e0ba..bce0fe2 100644 --- a/src/handlers/generate-erc20-permit.ts +++ b/src/handlers/generate-erc20-permit.ts @@ -2,7 +2,7 @@ import { PERMIT2_ADDRESS, PermitTransferFrom, SignatureTransfer } from "@uniswap import { ethers, keccak256, MaxInt256, parseUnits, toUtf8Bytes } from "ethers"; import { Context, Logger } from "../types/context"; import { PermitReward, TokenType } from "../types"; -import { decryptKeys } from "../utils"; +import { decrypt, parseDecryptedPrivateKey } from "../utils"; import { getFastestProvider } from "../utils/get-fastest-provider"; export interface Payload { @@ -74,10 +74,15 @@ export async function generateErc20PermitSignature( throw new Error("Provider is not defined"); } - const { privateKey } = await decryptKeys(_evmPrivateEncrypted); - if (!privateKey) { - const errorMessage = "Private key is not defined"; - _logger.fatal(errorMessage); + let privateKey = null; + try { + const privateKeyDecrypted = await decrypt(_evmPrivateEncrypted, String(process.env.X25519_PRIVATE_KEY)); + const privateKeyParsed = parseDecryptedPrivateKey(privateKeyDecrypted); + privateKey = privateKeyParsed.privateKey; + if (!privateKey) throw new Error("Private key is not defined"); + } catch (error) { + const errorMessage = `Failed to decrypt a private key: ${error}`; + _logger.error(errorMessage); throw new Error(errorMessage); } diff --git a/src/index.ts b/src/index.ts index 69c6f60..7510b2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from "./handlers"; export * from "./adapters"; export * from "./generate-permits-from-context"; export * from "./types"; +export * from "./utils"; diff --git a/src/utils/keys.ts b/src/utils/keys.ts index 16ae0e4..b2326b4 100644 --- a/src/utils/keys.ts +++ b/src/utils/keys.ts @@ -1,40 +1,101 @@ import sodium from "libsodium-wrappers"; -const KEY_PREFIX = "HSK_"; -export async function decryptKeys(cipherText: string): Promise<{ privateKey: string; publicKey: string } | { privateKey: null; publicKey: null }> { +/** + * Decrypts encrypted text with provided "X25519_PRIVATE_KEY" value + * @param encryptedText Encrypted text + * @param x25519PrivateKey "X25519_PRIVATE_KEY" private key + * @returns Decrypted text + */ +export async function decrypt(encryptedText: string, x25519PrivateKey: string): Promise { await sodium.ready; - let _public: null | string = null; - let _private: null | string = null; + const publicKey = await getPublicKey(x25519PrivateKey); + + const binaryPublic = sodium.from_base64(publicKey, sodium.base64_variants.URLSAFE_NO_PADDING); + const binaryPrivate = sodium.from_base64(x25519PrivateKey, sodium.base64_variants.URLSAFE_NO_PADDING); + const binaryEncryptedText = sodium.from_base64(encryptedText, sodium.base64_variants.URLSAFE_NO_PADDING); - const X25519_PRIVATE_KEY = process.env.X25519_PRIVATE_KEY; + const decryptedText = sodium.crypto_box_seal_open(binaryEncryptedText, binaryPublic, binaryPrivate, "text"); - if (!X25519_PRIVATE_KEY) { - console.warn("X25519_PRIVATE_KEY is not defined"); - return { privateKey: null, publicKey: null }; - } - _public = await getScalarKey(X25519_PRIVATE_KEY); - if (!_public) { - console.warn("Public key is null"); - return { privateKey: null, publicKey: null }; - } - if (!cipherText?.length) { - console.warn("No cipherText was provided"); - return { privateKey: null, publicKey: null }; - } - const binaryPublic = sodium.from_base64(_public, sodium.base64_variants.URLSAFE_NO_PADDING); - const binaryPrivate = sodium.from_base64(X25519_PRIVATE_KEY, sodium.base64_variants.URLSAFE_NO_PADDING); + return decryptedText; +} - const binaryCipher = sodium.from_base64(cipherText, sodium.base64_variants.URLSAFE_NO_PADDING); +/** + * Returns public key from provided "X25519_PRIVATE_KEY" value + * @param x25519PrivateKey "X25519_PRIVATE_KEY" private key + * @returns Public key + */ +export async function getPublicKey(x25519PrivateKey: string): Promise { + await sodium.ready; + const binaryPrivate = sodium.from_base64(x25519PrivateKey, sodium.base64_variants.URLSAFE_NO_PADDING); + return sodium.crypto_scalarmult_base(binaryPrivate, "base64"); +} - const walletPrivateKey: string | null = sodium.crypto_box_seal_open(binaryCipher, binaryPublic, binaryPrivate, "text"); - _private = walletPrivateKey?.replace(KEY_PREFIX, ""); +/** + * Parses partner's private key into object with properties: + * 1. Private key + * 2. Organization id where this private key is allowed to be used + * 3. Repository id where this private key is allowed to be used + * + * The issue with "plain" encryption of wallet private keys is that if partner accidentally shares + * his encrypted private key then a malicious user will be able to use that leaked private key + * in another organization with permits generated from a leaked partner's wallet. + * + * Partner private key (`evmPrivateEncrypted` config param in `conversation-rewards` plugin) supports 3 formats: + * 1. PRIVATE_KEY + * 2. PRIVATE_KEY:GITHUB_ORGANIZATION_ID + * 3. PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID + * + * Format "PRIVATE_KEY" can be used only for `ubiquity` and `ubiquibot` organizations. It is + * kept for backwards compatibility in order not to update private key formats for our existing + * values set in the `evmPrivateEncrypted` param. + * + * Format "PRIVATE_KEY:GITHUB_ORGANIZATION_ID" restricts in which particular organization this private + * key can be used. It can be set either in the organization wide config either in the repository wide one. + * + * Format "PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID" restricts organization and a particular + * repository where private key is allowed to be used. + * + * @param decryptedPrivateKey Decrypted private key string (in any of the 3 different formats) + * @returns Parsed private key object: private key, organization id and repository id + */ +export function parseDecryptedPrivateKey(decryptedPrivateKey: string) { + let result: { + privateKey: string | null, + allowedOrganizationId: number | null, + allowedRepositoryId: number | null, + } = { + privateKey: null, + allowedOrganizationId: null, + allowedRepositoryId: null, + }; + + // split private key + const privateKeyParts = decryptedPrivateKey.split(":"); - return { privateKey: _private, publicKey: _public }; -} + // Plain private key. + // Format: "PRIVATE_KEY". + // Used for backwards compatibility with ubiquity related organizations: + // - https://github.com/ubiquity + // - https://github.com/ubiquibot + if (privateKeyParts.length === 1) { + result.privateKey = privateKeyParts[0]; + } -async function getScalarKey(x25519PrivateKey: string) { - await sodium.ready; - const binPriv = sodium.from_base64(x25519PrivateKey, sodium.base64_variants.URLSAFE_NO_PADDING); - return sodium.crypto_scalarmult_base(binPriv, "base64"); + // Private key + allowed organization id. + // Format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID" + if (privateKeyParts.length === 2) { + result.privateKey = privateKeyParts[0]; + result.allowedOrganizationId = Number(privateKeyParts[1]); + } + + // Private key + allowed organization id + allowed repository id. + // Format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID" + if (privateKeyParts.length === 3) { + result.privateKey = privateKeyParts[0]; + result.allowedOrganizationId = Number(privateKeyParts[1]); + result.allowedRepositoryId = Number(privateKeyParts[2]); + } + + return result; } diff --git a/tests/generate-erc20-permit.test.ts b/tests/generate-erc20-permit.test.ts index 551f923..2cfe63e 100644 --- a/tests/generate-erc20-permit.test.ts +++ b/tests/generate-erc20-permit.test.ts @@ -76,9 +76,9 @@ describe("generateErc20PermitSignature", () => { it("should throw error when evmPrivateEncrypted is not defined", async () => { const amount = 0; - - await expect(generateErc20PermitSignature(context, SPENDER, amount, ERC20_REWARD_TOKEN_ADDRESS)).rejects.toThrow("Private key is not defined"); - expect(context.logger.fatal).toHaveBeenCalledWith("Private key is not defined"); + const expectedError = "Failed to decrypt a private key: TypeError: input cannot be null or undefined"; + await expect(generateErc20PermitSignature(context, SPENDER, amount, ERC20_REWARD_TOKEN_ADDRESS)).rejects.toThrow(expectedError); + expect(context.logger.error).toHaveBeenCalledWith(expectedError); }); it("should return error message when no wallet found for user", async () => { diff --git a/tests/utils/keys.test.ts b/tests/utils/keys.test.ts new file mode 100644 index 0000000..3c59003 --- /dev/null +++ b/tests/utils/keys.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@jest/globals"; +import { decrypt, getPublicKey, parseDecryptedPrivateKey } from "../../src/utils"; + +// dummy value for testing purposes +const X25519_PRIVATE_KEY = "wrQ9wTI1bwdAHbxk2dfsvoK1yRwDc0CEenmMXFvGYgY"; + +describe("keys", () => { + describe("decrypt()", () => { + it("Should decrypt encrypted text", async () => { + // encrypted "test" + const encryptedText = 'RZcKYqzwb6zeRHCJcV5QxGKrNPEll-xyRW_bNNa2rw3bddnjX2Kd-ycPvGq1NocSAHJR2w'; + const decryptedText = await decrypt(encryptedText, X25519_PRIVATE_KEY); + expect(decryptedText).toEqual('test'); + }); + }); + + describe("getPublicKey()", () => { + it("Should return public key from private key", async () => { + const publicKey = await getPublicKey(X25519_PRIVATE_KEY); + expect(publicKey).toEqual('iHYr7Zy077eoAvunTB_-DQIq5Nz73H_nIYaS_buiQjo'); + }); + }); + + describe("parseDecryptedPrivateKey()", () => { + it("Should return parsed private key for format PRIVATE_KEY", async () => { + // encrypted "test" + const encryptedText = 'RZcKYqzwb6zeRHCJcV5QxGKrNPEll-xyRW_bNNa2rw3bddnjX2Kd-ycPvGq1NocSAHJR2w'; + const decryptedText = await decrypt(encryptedText, X25519_PRIVATE_KEY); + const parsedPrivateKey = parseDecryptedPrivateKey(decryptedText); + expect(parsedPrivateKey).toEqual({ + privateKey: "test", + allowedOrganizationId: null, + allowedRepositoryId: null, + }); + }); + + it("Should return parsed private key for format PRIVATE_KEY:GITHUB_ORGANIZATION_ID", async () => { + // encrypted "test:1" + const encryptedText = '6VWlePw3pf7XED3OXl2C8SBxdZ5i-yj214OI43TaChXhWxNHSQL2wHOyqNXqjcuedKVOW8HC'; + const decryptedText = await decrypt(encryptedText, X25519_PRIVATE_KEY); + const parsedPrivateKey = parseDecryptedPrivateKey(decryptedText); + expect(parsedPrivateKey).toEqual({ + privateKey: "test", + allowedOrganizationId: 1, + allowedRepositoryId: null, + }); + }); + + it("Should return parsed private key for format PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID", async () => { + // encrypted "test:1:2" + const encryptedText = 'q1yDNgeKQTiztJH8gfKH2cX77eC6BfvaSMjCxl7Q-Fj79LICsNBQOtjOBUXJoUdBqtbvI3OCvuw'; + const decryptedText = await decrypt(encryptedText, X25519_PRIVATE_KEY); + const parsedPrivateKey = parseDecryptedPrivateKey(decryptedText); + expect(parsedPrivateKey).toEqual({ + privateKey: "test", + allowedOrganizationId: 1, + allowedRepositoryId: 2, + }); + }); + }); +});