diff --git a/README.md b/README.md index de984994..b6f7b83c 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,32 @@ with: post: true debug: false ``` + +## How to encrypt the `evmPrivateEncrypted` parameter + +Partner private key (`evmPrivateEncrypted` config param in `conversation-rewards` plugin) supports 2 formats: +1. `PRIVATE_KEY:GITHUB_OWNER_ID` +2. `PRIVATE_KEY:GITHUB_OWNER_ID:GITHUB_REPOSITORY_ID` + +Here `GITHUB_OWNER_ID` can be: +1. Github organization id (if ubiquibot is used within an organization) +2. Github user id (if ubiquibot is simply installed in a user's repository) + +Format `PRIVATE_KEY:GITHUB_OWNER_ID` restricts in which particular organization (or user related repositories) +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_OWNER_ID:GITHUB_REPOSITORY_ID` restricts organization (or user related repositories) and a particular repository where private key is allowed to be used. + +How to encrypt for you local organization for testing purposes: +1. Get your organization (or user) id +``` +curl -H "Accept: application/json" -H "Authorization: token GITHUB_PAT_TOKEN" https://api.github.com/orgs/ubiquity +``` +2. Open https://keygen.ubq.fi/ +3. Click "Generate" to create a new `x25519_PRIVATE_KEY` (which will be used in the `conversation-rewards` plugin to decrypt encrypted wallet private key) +4. Input a string in the format `PRIVATE_KEY:GITHUB_OWNER_ID` in the `PLAIN_TEXT` UI text input where: +- `PRIVATE_KEY`: your ethereum wallet private key without the `0x` prefix +- `GITHUB_OWNER_ID`: your github organization id or user id (which you got from step 1) +5. Click "Encrypt" to get an encrypted value in the `CIPHER_TEXT` field +6. Set the encrypted text (from step 5) in the `evmPrivateEncrypted` config parameter +7. Set `X25519_PRIVATE_KEY` environment variable in github secrets of your forked instance of the `conversation-rewards` plugin diff --git a/src/parser/permit-generation-module.ts b/src/parser/permit-generation-module.ts index 4ec7422a..c73418ae 100644 --- a/src/parser/permit-generation-module.ts +++ b/src/parser/permit-generation-module.ts @@ -11,6 +11,7 @@ import { SupportedEvents, TokenType, } from "@ubiquibot/permit-generation/core"; +import { decrypt, parseDecryptedPrivateKey } from "@ubiquibot/permit-generation/utils"; import Decimal from "decimal.js"; import configuration from "../configuration/config-reader"; import { @@ -53,6 +54,11 @@ export class PermitGenerationModule implements Module { console.warn("[PermitGenerationModule] Invalid env detected, skipping."); return Promise.resolve(result); } + const isPrivateKeyAllowed = await this._isPrivateKeyAllowed(payload.evmPrivateEncrypted, program.eventPayload.repository.owner.id, program.eventPayload.repository.id); + if (!isPrivateKeyAllowed) { + console.warn("[PermitGenerationModule] Private key is not allowed to be used in this organization/repository."); + return Promise.resolve(result); + } const eventName = context.eventName as SupportedEvents; const octokit = getOctokitInstance(); const logger = { @@ -267,6 +273,72 @@ export class PermitGenerationModule implements Module { } } + /** + * Checks whether partner's private key is allowed to be used in current repository. + * + * 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 2 formats: + * 1. PRIVATE_KEY:GITHUB_OWNER_ID + * 2. PRIVATE_KEY:GITHUB_OWNER_ID:GITHUB_REPOSITORY_ID + * + * Here `GITHUB_OWNER_ID` can be: + * 1. Github organization id (if ubiquibot is used within an organization) + * 2. Github user id (if ubiquibot is simply installed in a user's repository) + * + * Format "PRIVATE_KEY:GITHUB_OWNER_ID" restricts in which particular organization (or user related repositories) + * 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_OWNER_ID:GITHUB_REPOSITORY_ID" restricts organization (or user related repositories) and + * a particular repository where private key is allowed to be used. + * + * @param privateKeyEncrypted Encrypted private key (with "X25519_PRIVATE_KEY") string (in any of the 2 different formats) + * @param githubContextOwnerId Github organization or used id from which the "conversation-rewards" is executed + * @param githubContextRepositoryId Github repository id from which the "conversation-rewards" is executed + * @returns Whether private key is allowed to be used in current owner/repository context + */ + async _isPrivateKeyAllowed( + privateKeyEncrypted: string, + githubContextOwnerId: number, + githubContextRepositoryId: number + ): Promise { + // decrypt private key + const privateKeyDecrypted = await decrypt(privateKeyEncrypted, process.env.X25519_PRIVATE_KEY); + + // parse decrypted private key + const privateKeyParsed = parseDecryptedPrivateKey(privateKeyDecrypted); + if (!privateKeyParsed.privateKey) { + console.log("Private key could not be decrypted"); + return false; + } + + // private key + owner id + // Format: PRIVATE_KEY:GITHUB_OWNER_ID + if (privateKeyParsed.allowedOrganizationId && !privateKeyParsed.allowedRepositoryId) { + if (privateKeyParsed.allowedOrganizationId !== githubContextOwnerId) { + console.log(`Current organization/user id ${githubContextOwnerId} is not allowed to use this private key`); + return false; + } + return true; + } + + // private key + owner id + repository id + // Format: PRIVATE_KEY:GITHUB_OWNER_ID:GITHUB_REPOSITORY_ID + if (privateKeyParsed.allowedOrganizationId && privateKeyParsed.allowedRepositoryId) { + if (privateKeyParsed.allowedOrganizationId !== githubContextOwnerId || privateKeyParsed.allowedRepositoryId !== githubContextRepositoryId) { + console.log(`Current organization/user id ${githubContextOwnerId} and repository id ${githubContextRepositoryId} are not allowed to use this private key`); + return false; + } + return true; + } + + // otherwise invalid private key format + console.log("Invalid private key format"); + return false; + } + get enabled(): boolean { if (!Value.Check(permitGenerationConfigurationType, this._configuration)) { console.warn("Invalid / missing configuration detected for PermitGenerationModule, disabling."); diff --git a/tests/__mocks__/results/valid-configuration.json b/tests/__mocks__/results/valid-configuration.json index 21392152..1a8e723d 100644 --- a/tests/__mocks__/results/valid-configuration.json +++ b/tests/__mocks__/results/valid-configuration.json @@ -5,7 +5,7 @@ }, "erc20RewardToken": "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", "evmNetworkId": 100, - "evmPrivateEncrypted": "kmpTKq5Wh9r9x5j3U9GqZr3NYnjK2g0HtbzeUBOuLC2y3x8ja_SKBNlB2AZ6LigXHP_HeMitftVUtzmoj8CFfVP9SqjWoL6IPku1hVTWkdTn97g1IxzmjydFxjdcf0wuDW1hvVtoq3Uw5yALABqxcQ", + "evmPrivateEncrypted": "qGslGsT9bfY0C3uc_utvFhBN_Zwggf0sONEFOfF67E_gWDXqnV3qC-LnGj2ufIHJwdHxFLz3VVzup2MrGtFtHhZ2WZZvq_8NQi48LFbr5b6OIxL-vOXooAyQYfb2mjLmcsYaxXc5eu1mUfO9rfENSAw9dFeWfi9VKQ", "incentives": { "contentEvaluator": { "multipliers": [ diff --git a/tests/parser/permit-generation-module.test.ts b/tests/parser/permit-generation-module.test.ts index 8fea339b..c04c5f44 100644 --- a/tests/parser/permit-generation-module.test.ts +++ b/tests/parser/permit-generation-module.test.ts @@ -163,4 +163,121 @@ describe("permit-generation-module.ts", () => { expect(resultAfterFees["ubiquibot-treasury"].total).toEqual(11.11); }); }); + + describe("_isPrivateKeyAllowed()", () => { + beforeEach(() => { + // set dummy X25519_PRIVATE_KEY + process.env.X25519_PRIVATE_KEY = "wrQ9wTI1bwdAHbxk2dfsvoK1yRwDc0CEenmMXFvGYgY"; + }); + + it("Should return false if private key could not be decrypted", async () => { + const permitGenerationModule = new PermitGenerationModule(); + const spyConsoleLog = jest.spyOn(console, "log"); + + // format: "PRIVATE_KEY" + // encrypted value: "" + const privateKeyEncrypted = "Y-29-JttQ7xNBMOQylST_9kgVjVKnvkYlyihbqsBwGDmuBi7ZlaIh1I6cTDzrMiR"; + const githubContextOrganizationId = 1; + const githubContextRepositoryId = 2; + + const result = await permitGenerationModule._isPrivateKeyAllowed(privateKeyEncrypted, githubContextOrganizationId, githubContextRepositoryId); + + expect(result).toEqual(false); + expect(spyConsoleLog).toHaveBeenCalledWith("Private key could not be decrypted"); + }); + + it("Should return false if private key is used in unallowed organization", async () => { + const permitGenerationModule = new PermitGenerationModule(); + const spyConsoleLog = jest.spyOn(console, "log"); + + // format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID" + // encrypted value: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80:1" + const privateKeyEncrypted = "fdsmuUN_jTF-VAWMe55ozcg6AuLOKiyJm8unRg1QwnY9u_fsKmczRtekx6aq59ndQ0RDJ803SkeTOlUW87cd93rDTiq57ErxkRwq4j4SKYTitChIWAZw0-LCJAd2IvRmN9qVzA7oXEdkUihXkErGGtqK"; + const githubContextOrganizationId = 99; + const githubContextRepositoryId = 2; + + const result = await permitGenerationModule._isPrivateKeyAllowed(privateKeyEncrypted, githubContextOrganizationId, githubContextRepositoryId); + + expect(result).toEqual(false); + expect(spyConsoleLog).toHaveBeenCalledWith("Current organization/user id 99 is not allowed to use this private key"); + }); + + it("Should return true if private key is used in allowed organization", async () => { + const permitGenerationModule = new PermitGenerationModule(); + const spyConsoleLog = jest.spyOn(console, "log"); + + // format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID" + // encrypted value: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80:1" + const privateKeyEncrypted = "fdsmuUN_jTF-VAWMe55ozcg6AuLOKiyJm8unRg1QwnY9u_fsKmczRtekx6aq59ndQ0RDJ803SkeTOlUW87cd93rDTiq57ErxkRwq4j4SKYTitChIWAZw0-LCJAd2IvRmN9qVzA7oXEdkUihXkErGGtqK"; + const githubContextOrganizationId = 1; + const githubContextRepositoryId = 2; + + const result = await permitGenerationModule._isPrivateKeyAllowed(privateKeyEncrypted, githubContextOrganizationId, githubContextRepositoryId); + + expect(result).toEqual(true); + }); + + it("Should return false if private key is used in unallowed organization and allowed repository", async () => { + const permitGenerationModule = new PermitGenerationModule(); + const spyConsoleLog = jest.spyOn(console, "log"); + + // format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID" + // encrypted value: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80:1:2" + const privateKeyEncrypted = "mgLMdW_zfTYn3oNB5O0RBvPQOU4SkE1dOnhc6IGrgTQkkEJB7tvaEHdbZS0dEnq4VK21yShd5zdaRMbl6W2B6ij5tkrODH5-NEd8Uvp4Ks-NqrG-V3GkKrCJqCz3Cci3jrXU_rdn3Uil03d41eB4xluR_g8"; + const githubContextOrganizationId = 99; + const githubContextRepositoryId = 2; + + const result = await permitGenerationModule._isPrivateKeyAllowed(privateKeyEncrypted, githubContextOrganizationId, githubContextRepositoryId); + + expect(result).toEqual(false); + expect(spyConsoleLog).toHaveBeenCalledWith("Current organization/user id 99 and repository id 2 are not allowed to use this private key"); + }); + + it("Should return false if private key is used in allowed organization and unallowed repository", async () => { + const permitGenerationModule = new PermitGenerationModule(); + const spyConsoleLog = jest.spyOn(console, "log"); + + // format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID" + // encrypted value: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80:1:2" + const privateKeyEncrypted = "mgLMdW_zfTYn3oNB5O0RBvPQOU4SkE1dOnhc6IGrgTQkkEJB7tvaEHdbZS0dEnq4VK21yShd5zdaRMbl6W2B6ij5tkrODH5-NEd8Uvp4Ks-NqrG-V3GkKrCJqCz3Cci3jrXU_rdn3Uil03d41eB4xluR_g8"; + const githubContextOrganizationId = 1; + const githubContextRepositoryId = 99; + + const result = await permitGenerationModule._isPrivateKeyAllowed(privateKeyEncrypted, githubContextOrganizationId, githubContextRepositoryId); + + expect(result).toEqual(false); + expect(spyConsoleLog).toHaveBeenCalledWith("Current organization/user id 1 and repository id 99 are not allowed to use this private key"); + }); + + it("Should return true if private key is used in allowed organization and repository", async () => { + const permitGenerationModule = new PermitGenerationModule(); + const spyConsoleLog = jest.spyOn(console, "log"); + + // format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID" + // encrypted value: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80:1:2" + const privateKeyEncrypted = "mgLMdW_zfTYn3oNB5O0RBvPQOU4SkE1dOnhc6IGrgTQkkEJB7tvaEHdbZS0dEnq4VK21yShd5zdaRMbl6W2B6ij5tkrODH5-NEd8Uvp4Ks-NqrG-V3GkKrCJqCz3Cci3jrXU_rdn3Uil03d41eB4xluR_g8"; + const githubContextOrganizationId = 1; + const githubContextRepositoryId = 2; + + const result = await permitGenerationModule._isPrivateKeyAllowed(privateKeyEncrypted, githubContextOrganizationId, githubContextRepositoryId); + + expect(result).toEqual(true); + }); + + it("Should return false if private key format is invalid", async () => { + const permitGenerationModule = new PermitGenerationModule(); + const spyConsoleLog = jest.spyOn(console, "log"); + + // format: "PRIVATE_KEY:GITHUB_ORGANIZATION_ID:GITHUB_REPOSITORY_ID" + // encrypted value: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80:0:2" + const privateKeyEncrypted = "RIocCo0h_tMvLWieOZYPWzP7l7jVnVAno_QELfyUrRwVsN8aOHlx5hw5Be41bX74_Xaqef-gwTToXfdbgbqgLhLG0fxtw-QxKkWtzVnMlqO-WA2WVuaf3BpyiGFbVyyvFyFY_Q_O9gxY_F3xBPmHNfAwCPs"; + const githubContextOrganizationId = 1; + const githubContextRepositoryId = 2; + + const result = await permitGenerationModule._isPrivateKeyAllowed(privateKeyEncrypted, githubContextOrganizationId, githubContextRepositoryId); + + expect(result).toEqual(false); + expect(spyConsoleLog).toHaveBeenCalledWith("Invalid private key format"); + }); + }); }); diff --git a/tests/process.issue.test.ts b/tests/process.issue.test.ts index f1aac0f0..76dec7ca 100644 --- a/tests/process.issue.test.ts +++ b/tests/process.issue.test.ts @@ -63,6 +63,7 @@ jest.mock("../src/parser/command-line", () => { name: "conversation-rewards", owner: { login: "ubiquibot", + id: 76412717, // https://github.com/ubiquity }, }, }, diff --git a/tests/rewards.test.ts b/tests/rewards.test.ts index da8fc1d2..45aaca7e 100644 --- a/tests/rewards.test.ts +++ b/tests/rewards.test.ts @@ -131,6 +131,7 @@ jest.mock("../src/parser/command-line", () => { name: "conversation-rewards", owner: { login: "ubiquibot", + id: 76412717, // https://github.com/ubiquity }, }, },