Skip to content

Commit

Permalink
Merge pull request #63 from ubiquibot/development
Browse files Browse the repository at this point in the history
Dev to main
  • Loading branch information
rndquu authored Sep 9, 2024
2 parents 181f3e1 + bc12c57 commit fd05a11
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 38 deletions.
2 changes: 1 addition & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"version": "0.2",
"ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log", "supabase", "bun.lockb"],
"ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log", "supabase", "bun.lockb", "tests"],
"useGitignore": true,
"language": "en",
"words": ["dataurl", "devpool", "outdir", "servedir", "typebox"],
Expand Down
15 changes: 10 additions & 5 deletions src/handlers/generate-erc20-permit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./handlers";
export * from "./adapters";
export * from "./generate-permits-from-context";
export * from "./types";
export * from "./utils";
119 changes: 90 additions & 29 deletions src/utils/keys.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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;
}
6 changes: 3 additions & 3 deletions tests/generate-erc20-permit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
61 changes: 61 additions & 0 deletions tests/utils/keys.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});

0 comments on commit fd05a11

Please sign in to comment.