diff --git a/.github/workflows/app-build.yml b/.github/workflows/pull-request-build-test.yml similarity index 93% rename from .github/workflows/app-build.yml rename to .github/workflows/pull-request-build-test.yml index 3eebb8e..7a8d17c 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/pull-request-build-test.yml @@ -1,4 +1,4 @@ -name: Pull request build +name: Pull request build and test on: pull_request: @@ -29,3 +29,6 @@ jobs: NEXT_PUBLIC_IPFS_API_KEY: x NEXT_PUBLIC_ETHERSCAN_API_KEY: x NODE_ENV: production + + - name: Run tests + run: bun test diff --git a/bun.lockb b/bun.lockb index 186ae6e..2c9ab69 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8fac7dc..cd646e4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "format": "prettier --check .; echo 'To write the changes: bun run format:fix'", "format:fix": "prettier -w . --list-different", "deploy-dao": "bun ./scripts/deploy.ts", - "prepare": "husky" + "prepare": "husky", + "test": "bun test" }, "lint-staged": { "*.{js, jsx,ts,tsx}": [ @@ -34,6 +35,7 @@ "ipfs-http-client": "^60.0.1", "next": "14.1.4", "react": "^18.2.0", + "libsodium-wrappers": "^0.7.13", "react-blockies": "^1.4.1", "react-dom": "^18.2.0", "tailwindcss-fluid-type": "^2.0.6", @@ -42,9 +44,11 @@ }, "devDependencies": { "@aragon/osx-commons-configs": "^0.2.0", + "@types/bun": "latest", "@types/dompurify": "^3.0.5", "@types/node": "^20.11.30", "@types/react": "^18.2.71", + "@types/libsodium-wrappers": "^0.7.13", "@types/react-blockies": "^1.4.4", "@types/react-dom": "^18.2.22", "autoprefixer": "^10.4.19", diff --git a/plugins/dualGovernance/components/proposal/index.tsx b/plugins/dualGovernance/components/proposal/index.tsx index a1671aa..120be7f 100644 --- a/plugins/dualGovernance/components/proposal/index.tsx +++ b/plugins/dualGovernance/components/proposal/index.tsx @@ -33,7 +33,7 @@ export default function ProposalCard(props: ProposalInputs) { address: PUB_TOKEN_ADDRESS, abi: erc20Votes, functionName: "getPastTotalSupply", - args: [proposal?.parameters.snapshotBlock], + args: [proposal?.parameters.snapshotBlock || BigInt(0)], }); const proposalVariant = useProposalStatus(proposal!); @@ -86,7 +86,7 @@ export default function ProposalCard(props: ProposalInputs) { result={{ option: "Veto", voteAmount: proposal.vetoTally.toString(), - votePercentage: Number((proposal?.vetoTally * BigInt(100)) / pastSupply), + votePercentage: pastSupply ? Number((proposal?.vetoTally * BigInt(100)) / pastSupply) : 0, }} publisher={[{ address: proposal.creator }]} // Fix: Pass an object of type IPublisher instead of a string status={proposalVariant!} diff --git a/plugins/multisig/hooks/useProposalExecute.tsx b/plugins/multisig/hooks/useProposalExecute.tsx index 85ed10f..5f576d5 100644 --- a/plugins/multisig/hooks/useProposalExecute.tsx +++ b/plugins/multisig/hooks/useProposalExecute.tsx @@ -1,9 +1,9 @@ import { useEffect } from "react"; import { useReadContract, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; -import { MultisigPluginAbi } from "../artifacts/MultisigPlugin"; import { AlertContextProps, useAlerts } from "@/context/Alerts"; import { useRouter } from "next/router"; import { PUB_CHAIN, PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS } from "@/constants"; +import { OptimisticTokenVotingPluginAbi } from "@/plugins/dualGovernance/artifacts/OptimisticTokenVotingPlugin.sol"; export function useProposalExecute(proposalId: string) { const { reload } = useRouter(); diff --git a/tests/asymmetric.test.ts b/tests/asymmetric.test.ts new file mode 100644 index 0000000..9f5a381 --- /dev/null +++ b/tests/asymmetric.test.ts @@ -0,0 +1,90 @@ +import { expect, test, describe, beforeAll } from "bun:test"; +import { + decryptBytes, + decryptString, + encrypt, + generateKeyPair, + getSeededKeyPair, + computePublicKey, +} from "../utils/encryption/asymmetric"; +import libsodium from "libsodium-wrappers"; + +describe("Symmetric encryption", () => { + beforeAll(async () => { + await libsodium.ready; + }); + + test("Generates a random key pair", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + + expect(libsodium.to_hex(alice.keyType)).toBe(libsodium.to_hex(bob.keyType)); + expect(libsodium.to_hex(alice.privateKey)).not.toBe( + libsodium.to_hex(bob.privateKey) + ); + expect(libsodium.to_hex(alice.publicKey)).not.toBe( + libsodium.to_hex(bob.publicKey) + ); + }); + + test("Computes the public key given the secret one", () => { + const alice = generateKeyPair(); + const computedPubKey = computePublicKey(alice.privateKey); + + expect(libsodium.to_hex(alice.publicKey)).toBe( + libsodium.to_hex(computedPubKey) + ); + }); + + test("Generates a seeded key pair", () => { + const alice = getSeededKeyPair( + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + ); + const bob = getSeededKeyPair( + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + ); + + expect(libsodium.to_hex(alice.keyType)).toBe(libsodium.to_hex(bob.keyType)); + expect(libsodium.to_hex(alice.privateKey)).toBe( + libsodium.to_hex(bob.privateKey) + ); + expect(libsodium.to_hex(alice.publicKey)).toBe( + libsodium.to_hex(bob.publicKey) + ); + }); + + test("Encrypts and decrypts a string", () => { + const bob = generateKeyPair(); + const ciphertext = encrypt("Hello world", bob.publicKey); + expect(ciphertext.length).toBe(59); + const decrypted = decryptString(ciphertext, bob); + expect(decrypted).toBe("Hello world"); + const decryptedHex = decryptBytes(ciphertext, bob); + expect(libsodium.to_hex(decryptedHex)).toBe("48656c6c6f20776f726c64"); + }); + + test("Encrypts and decrypts a buffer", () => { + const bob = generateKeyPair(); + const bytes = new Uint8Array([10, 15, 50, 55, 80, 85]); + const ciphertext = encrypt(bytes, bob.publicKey); + expect(ciphertext.length).toBe(54); + const decryptedHex = decryptBytes(ciphertext, bob); + expect(libsodium.to_hex(decryptedHex)).toBe("0a0f32375055"); + }); + + test("Unintended keys can't decrypt", () => { + const bob = generateKeyPair(); + const cindy = generateKeyPair(); + + const bytes = new Uint8Array([10, 15, 50, 55, 80, 85]); + + const ciphertext1 = encrypt(bytes, bob.publicKey); + const ciphertext2 = encrypt("Hello world", bob.publicKey); + + expect(() => decryptBytes(ciphertext1, bob)).not.toThrow(); + expect(() => decryptString(ciphertext2, bob)).not.toThrow(); + + expect(() => decryptBytes(ciphertext1, cindy)).toThrow(); + expect(() => decryptString(ciphertext2, cindy)).toThrow(); + }); +}); diff --git a/tests/proposal-encryption.test.ts b/tests/proposal-encryption.test.ts new file mode 100644 index 0000000..61f63c4 --- /dev/null +++ b/tests/proposal-encryption.test.ts @@ -0,0 +1,187 @@ +import { expect, test, describe, beforeAll } from "bun:test"; +import { + encryptProposal, + encryptSymmetricKey, + decryptProposal, + decryptSymmetricKey, +} from "../utils/encryption/index"; +import libsodium from "libsodium-wrappers"; +import { generateSymmetricKey } from "@/utils/encryption/symmetric"; +import { generateKeyPair } from "@/utils/encryption/asymmetric"; + +describe("Proposal data encryption", () => { + beforeAll(async () => { + await libsodium.ready; + }); + + test("Encrypts a proposal data with the returned random, symmetric key", () => { + const metadata = { + title: "Proposal", + description: "Testing encryption", + }; + const actionBytes = new Uint8Array([ + 50, 25, 40, 200, 123, 234, 55, 26, 73, 84, 62, 162, 188, 126, 255, 0, 2, + 0, 5, 1, 55, 26, 37, 82, + ]); + + const { data: data1, symmetricKey: symmetricKey1 } = encryptProposal( + metadata, + actionBytes + ); + const { data: data2, symmetricKey: symmetricKey2 } = encryptProposal( + metadata, + actionBytes + ); + + expect(libsodium.to_hex(data1.metadata)).not.toBe( + libsodium.to_hex(data2.metadata) + ); + expect(libsodium.to_hex(data1.actions)).not.toBe( + libsodium.to_hex(data2.actions) + ); + expect(libsodium.to_hex(symmetricKey1)).not.toBe( + libsodium.to_hex(symmetricKey2) + ); + }); + + test("The returned symmetric key decrypts the original payload", () => { + const metadata1 = { + title: "Proposal 1", + description: "Testing encryption 1", + }; + const actionBytes1 = new Uint8Array([ + 50, 25, 40, 200, 123, 234, 55, 26, 73, 84, 62, 162, 188, 126, 255, 0, 2, + 0, 5, 1, 55, 26, 37, 82, + ]); + const metadata2 = { + title: "Proposal 2", + description: "Testing encryption 2", + }; + const actionBytes2 = new Uint8Array([ + 0, 5, 1, 55, 26, 37, 82, 50, 25, 40, 200, 123, 234, 55, 26, 73, 84, 62, + 162, 188, 126, 255, 0, 2, + ]); + + // Encrypt + const { data: data1, symmetricKey: symmetricKey1 } = encryptProposal( + metadata1, + actionBytes1 + ); + const { data: data2, symmetricKey: symmetricKey2 } = encryptProposal( + metadata2, + actionBytes2 + ); + + // Decrypt + const { metadata: dMetadata1, actions: dActions1 } = decryptProposal< + typeof metadata1 + >(data1, symmetricKey1); + const { metadata: dMetadata2, actions: dActions2 } = decryptProposal< + typeof metadata2 + >(data2, symmetricKey2); + + // Check + expect(dMetadata1.title).toBe(metadata1.title); + expect(dMetadata1.description).toBe(metadata1.description); + + expect(dMetadata2.title).toBe(metadata2.title); + expect(dMetadata2.description).toBe(metadata2.description); + + expect(libsodium.to_hex(dActions1)).toBe(libsodium.to_hex(actionBytes1)); + expect(libsodium.to_hex(dActions2)).toBe(libsodium.to_hex(actionBytes2)); + }); + + test("Keys different than the original one can't decrypt the payload", () => { + const metadata = { + title: "Proposal title", + description: "Proposal description", + }; + const actionBytes1 = new Uint8Array([ + 50, 73, 84, 62, 162, 188, 126, 255, 0, 2, 25, 40, 200, 123, 234, 55, 26, + 55, 26, 37, 82, 0, 5, 1, + ]); + + // Encrypt + const { data, symmetricKey } = encryptProposal(metadata, actionBytes1); + + const otherKeys = new Array(20).fill(0).map(() => generateSymmetricKey()); + + // Decrypt + expect(() => { + const { metadata: dMetadata, actions: dActions1 } = decryptProposal< + typeof metadata + >(data, symmetricKey); + + expect(dMetadata.title).toBe(metadata.title); + expect(dMetadata.description).toBe(metadata.description); + }).not.toThrow(); + + for (const otherKey of otherKeys) { + expect(() => { + decryptProposal(data, otherKey); + }).toThrow(); + } + }); +}); + +describe("Symmetric key encryption across members", () => { + beforeAll(async () => { + await libsodium.ready; + }); + + test("Encrypts a symmetric key for N recipients", () => { + const symKey = generateSymmetricKey(); + + const members = new Array(13).fill(0).map(() => generateKeyPair()); + const encryptedItems = encryptSymmetricKey( + symKey, + members.map((m) => m.publicKey) + ); + + expect(encryptedItems.length).toBe(members.length); + encryptedItems.forEach((item) => { + expect(item.length).toBe(80); + }); + }); + + test("Recipients can decrypt the original symmetric key", () => { + const symKey = generateSymmetricKey(); + const hexSymKey = libsodium.to_hex(symKey); + + const members = new Array(13).fill(0).map(() => generateKeyPair()); + const encryptedItems = encryptSymmetricKey( + symKey, + members.map((m) => m.publicKey) + ); + + for (let i = 0; i < encryptedItems.length; i++) { + const decryptedKey = decryptSymmetricKey(encryptedItems, members[i]); + expect(libsodium.to_hex(decryptedKey)).toBe(hexSymKey); + } + }); + + test("Non recipients cannot decrypt the original symmetric key", () => { + const symKey = generateSymmetricKey(); + + const members = new Array(13).fill(0).map(() => generateKeyPair()); + const intruders = new Array(members.length) + .fill(0) + .map(() => generateKeyPair()); + const encryptedItems = encryptSymmetricKey( + symKey, + members.map((m) => m.publicKey) + ); + + for (let i = 0; i < encryptedItems.length; i++) { + expect(() => { + decryptSymmetricKey(encryptedItems, intruders[i]); + }).toThrow(); + } + + for (let i = 0; i < encryptedItems.length; i++) { + expect(() => { + decryptSymmetricKey(encryptedItems, members[i]); + }).not.toThrow(); + } + }); +}); diff --git a/tests/symmetric.test.ts b/tests/symmetric.test.ts new file mode 100644 index 0000000..08d9dc9 --- /dev/null +++ b/tests/symmetric.test.ts @@ -0,0 +1,62 @@ +import { expect, test, describe, beforeAll } from "bun:test"; +import { + decryptBytes, + decryptString, + encrypt, + generateSymmetricKey, +} from "../utils/encryption/symmetric"; +import libsodium from "libsodium-wrappers"; + +describe("Symmetric encryption", () => { + beforeAll(async () => { + await libsodium.ready; + }); + + test("Generates a random symmetric key", () => { + const key1 = generateSymmetricKey(); + const key2 = generateSymmetricKey(); + + expect(libsodium.to_hex(key1)).not.toBe(libsodium.to_hex(key2)); + }); + + test("Encrypts and decrypts a string", () => { + const symKey = generateSymmetricKey(); + const encryptedPayload = encrypt("Hello world", symKey); + + expect(libsodium.to_hex(encryptedPayload)).toMatch(/^[0-9a-fA-F]+$/); + expect(encryptedPayload.length).toBe(51); + + const decrypted = decryptString(encryptedPayload, symKey); + expect(decrypted).toBe("Hello world"); + + const decryptedHex = decryptBytes(encryptedPayload, symKey); + expect(libsodium.to_hex(decryptedHex)).toBe("48656c6c6f20776f726c64"); + }); + + test("Encrypts and decrypts a buffer", () => { + const symKey = generateSymmetricKey(); + const bytes = new Uint8Array([10, 15, 50, 55, 80, 85]); + const encryptedPayload = encrypt(bytes, symKey); + + expect(libsodium.to_hex(encryptedPayload)).toMatch(/^[0-9a-fA-F]+$/); + expect(encryptedPayload.length).toBe(46); + + const decryptedHex = decryptBytes(encryptedPayload, symKey); + expect(libsodium.to_hex(decryptedHex)).toBe("0a0f32375055"); + }); + + test("Incorrect keys can't decrypt", () => { + const symKey = generateSymmetricKey(); + const wrongKey = generateSymmetricKey(); + + const bytes = new Uint8Array([10, 15, 50, 55, 80, 85]); + const encryptedPayload1 = encrypt(bytes, symKey); + const encryptedPayload2 = encrypt("Hello world", symKey); + + expect(() => decryptBytes(encryptedPayload1, symKey)).not.toThrow(); + expect(() => decryptString(encryptedPayload2, symKey)).not.toThrow(); + + expect(() => decryptBytes(encryptedPayload1, wrongKey)).toThrow(); + expect(() => decryptString(encryptedPayload2, wrongKey)).toThrow(); + }); +}); diff --git a/utils/encryption/asymmetric.ts b/utils/encryption/asymmetric.ts new file mode 100644 index 0000000..a8354bd --- /dev/null +++ b/utils/encryption/asymmetric.ts @@ -0,0 +1,46 @@ +import sodium, { KeyPair } from "libsodium-wrappers"; +export type { KeyPair } from "libsodium-wrappers"; + +export function encrypt( + message: string | Uint8Array, + recipientPubKey: Uint8Array +) { + return sodium.crypto_box_seal(message, recipientPubKey, "uint8array"); +} + +export function decryptString(ciphertext: Uint8Array, keyPair: KeyPair) { + const bytes = decryptBytes(ciphertext, keyPair); + return sodium.to_string(bytes); +} + +export function decryptBytes( + ciphertext: Uint8Array, + keyPair: KeyPair | Omit +): Uint8Array { + return sodium.crypto_box_seal_open( + ciphertext, + keyPair.publicKey, + keyPair.privateKey, + "uint8array" + ); +} + +// Key management + +export function generateKeyPair() { + return sodium.crypto_box_keypair(); +} + +export function getSeededKeyPair(hexSeed: string) { + if (!hexSeed.match(/^[0-9a-fA-F]+$/)) { + throw new Error("Invalid hexadecimal seed"); + } else if (hexSeed.length != 64) { + throw new Error("The hexadecimal seed should be 32 bytes long"); + } + const bytesSeed = sodium.from_hex(hexSeed); + return sodium.crypto_box_seed_keypair(bytesSeed); +} + +export function computePublicKey(secretKey: Uint8Array) { + return sodium.crypto_scalarmult_base(secretKey); +} diff --git a/utils/encryption/index.ts b/utils/encryption/index.ts new file mode 100644 index 0000000..0f4c301 --- /dev/null +++ b/utils/encryption/index.ts @@ -0,0 +1,89 @@ +import libsodium from "libsodium-wrappers"; +import { + generateSymmetricKey, + encrypt as symmetricEncrypt, + decryptString as symmetricDecryptString, + decryptBytes as symmetricDecryptBytes, +} from "./symmetric"; +import { + encrypt as asymmetricEncrypt, + decryptBytes as asymmetricDecryptBytes, + KeyPair, +} from "./asymmetric"; + +export type { KeyPair } from "./asymmetric"; +export type SymmetricKey = Uint8Array; + +type JsonLiteral = string | number | boolean; +type JsonValue = JsonLiteral | Array | { [k: string]: JsonValue }; + +export function encryptProposal(metadata: JsonValue, actionBytes: Uint8Array) { + const symmetricKey = generateSymmetricKey(); + + const strMetadata = JSON.stringify(metadata); + const encryptedMetadata = symmetricEncrypt(strMetadata, symmetricKey); + const encryptedActions = symmetricEncrypt(actionBytes, symmetricKey); + + return { + data: { + metadata: libsodium.to_base64(encryptedMetadata), + actions: libsodium.to_base64(encryptedActions), + }, + symmetricKey, + } as const; +} + +/** + * Returns a list of encrypted symKey payloads. Each position can be decrypted by the respective public key on the given list. + * @param symKey The symmetric key to encrypt + * @param recipientPubKeys The list of public keys to encrypt for + * @returns + */ +export function encryptSymmetricKey( + symKey: Uint8Array, + recipientPubKeys: Uint8Array[] +) { + return recipientPubKeys.map((pubKey) => { + return asymmetricEncrypt(symKey, pubKey); + }); +} + +/** + * + * @param encryptedItems The list of encrypted symmetric keys, one of which can be decrypted by the keyPair + * @param keyPair An object with the private and public keys + * @returns + */ +export function decryptSymmetricKey( + encryptedItems: Uint8Array[], + keyPair: KeyPair | Omit +) { + for (const item of encryptedItems) { + try { + // Attempt to decrypt, continue on fail + const decryptedSymKey = asymmetricDecryptBytes(item, keyPair); + return decryptedSymKey; + } catch (err) {} + } + throw new Error("The given keypair cannot decrypt any of the ciphertext's"); +} + +export function decryptProposal( + data: { metadata: string; actions: string }, + symmetricKey: SymmetricKey +): { + metadata: T; + actions: Uint8Array; +} { + if (!data["metadata"] || !data["actions"]) throw new Error("Invalid data"); + + const metadata = symmetricDecryptString( + libsodium.from_base64(data["metadata"]), + symmetricKey + ); + const actions = symmetricDecryptBytes( + libsodium.from_base64(data["actions"]), + symmetricKey + ); + return { metadata: JSON.parse(metadata), actions }; +} diff --git a/utils/encryption/symmetric.ts b/utils/encryption/symmetric.ts new file mode 100644 index 0000000..ee38c0d --- /dev/null +++ b/utils/encryption/symmetric.ts @@ -0,0 +1,52 @@ +import sodium from "libsodium-wrappers"; +import { concatenate } from "./util"; + +const SYM_KEY_LENGTH = 32; + +export function encrypt( + message: string | Uint8Array, + symmetricKey: Uint8Array +) { + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + return concatenate([ + nonce, + sodium.crypto_secretbox_easy(message, nonce, symmetricKey), + ]); +} + +export function decryptString( + nonceAndCiphertext: Uint8Array, + symmetricKey: Uint8Array +) { + const bytes = decryptBytes(nonceAndCiphertext, symmetricKey); + return sodium.to_string(bytes); +} + +export function decryptBytes( + nonceAndCiphertext: Uint8Array, + symmetricKey: Uint8Array +) { + const minLength = + sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES; + if (nonceAndCiphertext.length < minLength) { + throw "Invalid encrypted payload"; + } + + const nonce = nonceAndCiphertext.slice(0, sodium.crypto_secretbox_NONCEBYTES); + const ciphertext = nonceAndCiphertext.slice( + sodium.crypto_secretbox_NONCEBYTES + ); + + return sodium.crypto_secretbox_open_easy( + ciphertext, + nonce, + symmetricKey, + "uint8array" + ); +} + +// Key helpers + +export function generateSymmetricKey(size: number = SYM_KEY_LENGTH) { + return sodium.randombytes_buf(size, "uint8array"); +} diff --git a/utils/encryption/util.ts b/utils/encryption/util.ts new file mode 100644 index 0000000..6ca6bbc --- /dev/null +++ b/utils/encryption/util.ts @@ -0,0 +1,16 @@ +export function concatenate(arrays: Uint8Array[]) { + const totalLength = arrays.reduce( + (prev, uint8array) => prev + uint8array.byteLength, + 0 + ); + + const result = new Uint8Array(totalLength); + + let offset = 0; + arrays.forEach((entry) => { + result.set(entry, offset); + offset += entry.byteLength; + }); + + return result; +}