zhereh-frontend

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 32fb4e276af0f9d84eea340f4ea877a6a98ba26a
parent 573364608293674d8752f5cd5a39bd1de8675e4c
Author: William Muli <willi.wambu@gmail.com>
Date:   Tue, 25 Jul 2023 22:28:10 +0300

Added content schema and signing feature

Diffstat:
Mpackage-lock.json | 33+++++++++++++++++++++++++++++++++
Mpackage.json | 1+
Msrc/components/ProposalForm.svelte | 30+++++++++++++++++++++++-------
Msrc/components/ProposalView.svelte | 48++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/routes/api/cbor/decode/+server.ts | 17+++++++++++++++++
Asrc/routes/api/cbor/encode/+server.ts | 16++++++++++++++++
Msrc/shared/types.ts | 17+++++++++++++++++
Asrc/utils/content-schema.ts | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 278 insertions(+), 9 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@wagmi/chains": "^1.1.0", + "cbor": "^9.0.1", "ethers": "^6.5.1", "jssha": "^3.3.0", "svelte-french-toast": "^1.0.4", @@ -1353,6 +1354,17 @@ } ] }, + "node_modules/cbor": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.1.tgz", + "integrity": "sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==", + "dependencies": { + "nofilter": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/chai": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", @@ -2696,6 +2708,14 @@ "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", "dev": true }, + "node_modules/nofilter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", + "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", + "engines": { + "node": ">=12.19" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4968,6 +4988,14 @@ "integrity": "sha512-AZ+9tFXw1sS0o0jcpJQIXvFTOB/xGiQ4OQ2t98QX3NDn2EZTSRBC801gxrsGgViuq2ak/NLkNgSNEPtCr5lfKg==", "dev": true }, + "cbor": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.1.tgz", + "integrity": "sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==", + "requires": { + "nofilter": "^3.1.0" + } + }, "chai": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", @@ -5967,6 +5995,11 @@ "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", "dev": true }, + "nofilter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", + "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==" + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json @@ -38,6 +38,7 @@ "type": "module", "dependencies": { "@wagmi/chains": "^1.1.0", + "cbor": "^9.0.1", "ethers": "^6.5.1", "jssha": "^3.3.0", "svelte-french-toast": "^1.0.4", diff --git a/src/components/ProposalForm.svelte b/src/components/ProposalForm.svelte @@ -7,9 +7,12 @@ import { Routes } from '../shared/types' import { Wala } from '$lib/wala' import { computeHash } from '$lib/hash' + import { createSignedSchema, generateSchema } from '../utils/content-schema' + type ProposalFormData = { - description: string + description: string, + descriptionFile?: File targetVote: number blockWait: number } @@ -21,14 +24,22 @@ } let submitting: boolean = false + + function handleFileInput(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + formData.descriptionFile = file + } const onSubmit = async () => { - const { description, targetVote, blockWait } = formData + const { description, targetVote, blockWait, descriptionFile } = formData if (description === '' || targetVote === 0 || blockWait === 0) return try { submitting = true - await createWithoutOptions(description, blockWait, targetVote) + const contentSchema = generateSchema(description, descriptionFile) + const signedSchema = await createSignedSchema(contentSchema, $configuredChain, $connectionDetails.userAddress) + await createWithoutOptions(signedSchema, blockWait, targetVote) submitting = false goto(Routes.Home) } catch (error) { @@ -36,9 +47,9 @@ } } - const createWithoutOptions = async (description: string, blockWait: number, targetVote: number) => { - const localHash = await computeHash(description, 'SHA-256') - const descriptionHash = await new Wala().put(description, 'text/plain') + const createWithoutOptions = async (signedSchema: string, blockWait: number, targetVote: number) => { + const localHash = await computeHash(signedSchema, 'SHA-256') + const descriptionHash = await new Wala().put(signedSchema, 'text/plain') // compare hashes if (localHash !== descriptionHash) { console.error(`Computed description hash does not match wala hash. Local Hash: ${localHash}, Wala Hash: ${descriptionHash}`) @@ -80,7 +91,12 @@ placeholder="Enter proposal description" bind:value={formData.description} /> - </div> + <input + type="file" + class="file-input file-input-md file-input-bordered w-full my-4" + on:change={handleFileInput} + /> + </div> <div class="flex flex-col md:flex-row w-full gap-3 mt-3"> <div class="form-control w-full"> diff --git a/src/components/ProposalView.svelte b/src/components/ProposalView.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { createEventDispatcher, onMount } from "svelte" import { formatUnits } from "viem" - import type { Proposal } from "../shared/types" + import type { Content, Proposal } from "../shared/types" import { configuredChain, connectionDetails, voteToken } from "../store" import { copyToClipboard } from "../utils/copy-clipboard" import { publicClient, walletClient } from "../client" @@ -10,10 +10,12 @@ import { computeHash } from "../lib/hash" import { PUBLIC_VOTE_CONTRACT_ADDRESS } from "$env/static/public" import { voteContractAbi } from "../shared/token-vote-abi" + import { decodeCborHex, getFileNameFromContentDisposition } from "../utils/content-schema" // Variables export let proposal: Proposal let description: string | undefined + let descriptionContent: Content | undefined let currentProposalOptions: (string | undefined) [] = [] export let title: string | undefined = undefined let blockNumber: bigint @@ -83,12 +85,45 @@ } } + const downloadFile = () => { + if(descriptionContent?.body === undefined) return + // Remove the data URI prefix (e.g., "data:image/png;base64,") + const base64WithoutPrefix = descriptionContent.body.replace(/^data:[^;]+;base64,/, ''); + + // Convert the base64 string to a byte array + const byteCharacters = atob(base64WithoutPrefix); + const byteArrays = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteArrays[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteArrays); + + // Create a Blob from the byte array + const blob = new Blob([byteArray], { type: 'application/octet-stream' }); + + // Create a download link + const downloadLink = document.createElement('a'); + downloadLink.href = URL.createObjectURL(blob); + downloadLink.download = getFileNameFromContentDisposition(descriptionContent["content-disposition"]) + + // Append the link to the body and click it to trigger the download + document.body.appendChild(downloadLink); + downloadLink.click(); + + // Remove the download link from the DOM + document.body.removeChild(downloadLink); + } + // Lifecyle callbacks onMount(async () => { blockNumber = await publicClient($configuredChain).getBlockNumber() const wala = new Wala() if(proposal.description) { - description = await wala.get(proposal.description) as string + const descriptionHex = await wala.get(proposal.description) as string + const signedSchema = await decodeCborHex(descriptionHex) + const { signature, ...contentSchema } = signedSchema + description = contentSchema.subject + descriptionContent = contentSchema.content[1] as Content } if(proposal.options) { @@ -108,6 +143,15 @@ {description} </p> + {#if descriptionContent !== undefined && descriptionContent.body !== undefined} + <button + class="btn btn-primary text-white btn-sm normal-case my-4 w-[200px]" + on:click={downloadFile} + > + Download description file + </button> + {/if} + <p class="text-gray-500 text-sm mt-4">Options</p> {#if currentProposalOptions.length > 0} <ul class="list-disc list-inside"> diff --git a/src/routes/api/cbor/decode/+server.ts b/src/routes/api/cbor/decode/+server.ts @@ -0,0 +1,17 @@ +import * as cbor from 'cbor' +import { json, type RequestEvent } from '@sveltejs/kit' + +export async function POST({ request }: RequestEvent) { + const { hex }: { hex: string } = await request.json() + try { + const buffer = Buffer.from(hex, 'hex') + const content = cbor.decodeFirstSync(buffer) + + return json({ + content + }) + } catch (error) { + console.error(error) + return new Response(JSON.stringify(error), { status: 500 }) + } +} diff --git a/src/routes/api/cbor/encode/+server.ts b/src/routes/api/cbor/encode/+server.ts @@ -0,0 +1,16 @@ +import * as cbor from 'cbor' +import { json, type RequestEvent } from '@sveltejs/kit' + +export async function POST({ request }: RequestEvent) { + const { content } = await request.json() + try { + const buffer = await cbor.encodeAsync(content, { canonical: true }) + const hex = buffer.toString('hex') + return json({ + hex + }) + } catch (error) { + console.error(error) + return new Response(JSON.stringify(error), { status: 500 }); + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts @@ -51,3 +51,20 @@ export type NavOption = { name: string url: Routes } + +export interface Content { + 'content-type': string; + 'content-transfer-encoding': string; + 'content-disposition': string; + body: string; +} + +export interface ContentSchema { + subject: string; + date: string; + content: Content[]; +} + +export type SignedSchema = ContentSchema & { + signature?: string +} diff --git a/src/utils/content-schema.ts b/src/utils/content-schema.ts @@ -0,0 +1,125 @@ +import type { ContentSchema, SignedSchema } from '../shared/types' +import { walletClient, publicClient } from '../client' +import { recoverMessageAddress, type Chain } from 'viem' + +export function generateSchema( + description: string, + descriptionFile: File | undefined +): ContentSchema { + const currentDate = new Date().toUTCString() + + const schema: ContentSchema = { + subject: description, + date: currentDate, + content: [ + { + 'content-type': 'text/plain', + 'content-transfer-encoding': 'BASE64', + 'content-disposition': 'inline', + body: btoa(description) + } + ] + } + + if (descriptionFile !== undefined) { + const reader = new FileReader() + reader.onloadend = () => { + const body = btoa(reader.result as string) + console.log(body) + schema.content.push({ + 'content-type': descriptionFile.type, + 'content-transfer-encoding': 'BASE64', + 'content-disposition': `attachment; filename="${descriptionFile.name}"`, + body + }) + } + reader.onerror = (error) => console.error('Error reading file: ', error) + reader.readAsBinaryString(descriptionFile) + } + + return schema +} + +async function cborEncodeContent(content: ContentSchema | SignedSchema): Promise<string> { + const response = await fetch('/api/cbor/encode', { + method: 'POST', + body: JSON.stringify({ content }) + }) + + return (await response.json()).hex +} + +export async function decodeCborHex(hex: string): Promise<SignedSchema> { + const response = await fetch('/api/cbor/decode', { + method: 'POST', + body: JSON.stringify({ hex }) + }) + + return (await response.json()).content +} + +export async function signMessage(account: `0x${string}`, chain: Chain, message: string) { + const signature = await walletClient(chain).signMessage({ + account, + message + }) + return signature +} + +export async function createSignedSchema( + schema: ContentSchema, + chain: Chain, + account: `0x${string}` +): Promise<string> { + // Step 1: CBORify the schema + const cborSchemaHex = await cborEncodeContent(schema) + + // Step 2: Create a signature (Ethereum msg signature) on the CBOR itself + const signature = await signMessage(account, chain, cborSchemaHex) + + // Step 3: Add signature to the JSON schema + const signedSchema: SignedSchema = { + ...schema, + signature + } + + // Step 4: CBORify the schema again + const signedSchemaHex = await cborEncodeContent(signedSchema) + return signedSchemaHex +} + +export async function verifyContentSchema( + contentSchema: ContentSchema, + signature: `0x${string}`, + address: `0x${string}`, + chain: Chain, + originalHex: string +) { + const cborSchemaHex = await cborEncodeContent(contentSchema) + console.log(originalHex, cborSchemaHex) + + const recoveredAddress = await recoverMessageAddress({ + message: cborSchemaHex, + signature: signature + }) + + console.log('Recovered address: ', recoveredAddress) + const valid = await publicClient(chain).verifyMessage({ + message: cborSchemaHex, + address, + signature + }) + console.log('Message is valid', valid) + return valid +} + +export function getFileNameFromContentDisposition(contentDisposition: string): string { + const filenamePattern = /filename="([^"]+)"/ + const match = contentDisposition.match(filenamePattern) + + if (match) { + return match[1] + } else { + return '' + } +}