Skip to content

Commit

Permalink
[refactor] rewrite Election model with Web Crypto API (#381)
Browse files Browse the repository at this point in the history
Signed-off-by: TechQuery <shiy2008@gmail.com>
  • Loading branch information
TechQuery authored Jan 22, 2025
1 parent b856451 commit 9607cd4
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 220 deletions.
72 changes: 68 additions & 4 deletions models/Personnel/Election.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,80 @@
import { VoteTicket } from '@kaiyuanshe/kys-service';
import { BaseModel, toggle } from 'mobx-restful';
import { observable } from 'mobx';
import { BaseModel, persist, restore, toggle } from 'mobx-restful';

import { isServer } from '../Base';
import userStore from '../Base/User';

export const buffer2hex = (buffer: ArrayBufferLike) =>
Array.from(new Uint8Array(buffer), x => x.toString(16).padStart(2, '0')).join(
'',
);

export class ElectionModel extends BaseModel {
client = userStore.client;
algorithm = { name: 'ECDSA', namedCurve: 'P-384', hash: { name: 'SHA-256' } };

@persist()
@observable
accessor privateKey: CryptoKey | undefined;

@persist()
@observable
accessor publicKey = '';

@persist()
@observable
accessor currentVoteTicket: VoteTicket | undefined;

restored = !isServer() && restore(this, 'Electron');

@toggle('uploading')
async createVoteTicket(electionName: string) {
const { body } = await this.client.post<VoteTicket>(
`election/${electionName}/vote/ticket`,
async makePublicKey() {
await this.restored;

if (this.publicKey) return this.publicKey;

const { publicKey, privateKey } = await crypto.subtle.generateKey(
this.algorithm,
true,
['sign', 'verify'],
);
this.privateKey = privateKey;

const JWK = await crypto.subtle.exportKey('jwk', publicKey);

return (this.publicKey = btoa(JSON.stringify(JWK)));
}

@toggle('uploading')
async savePublicKey(electionName: string, jsonWebKey = this.publicKey) {
await userStore.restored;

const { body } = await this.client.post(
`election/${electionName}/public-key`,
{ jsonWebKey },
);
return body!;
}

@toggle('uploading')
async signVoteTicket(electionName: string) {
await this.restored;

if (this.currentVoteTicket) return this.currentVoteTicket;

await this.makePublicKey();
await this.savePublicKey(electionName);

const signature = await crypto.subtle.sign(
this.algorithm,
this.privateKey!,
new TextEncoder().encode(electionName),
);
return (this.currentVoteTicket = {
electionName,
publicKey: this.publicKey,
signature: buffer2hex(signature),
});
}
}
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"@giscus/react": "^3.1.0",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.1.5",
"@sentry/nextjs": "^8.50.0",
"@next/mdx": "^15.1.6",
"@sentry/nextjs": "^8.51.0",
"array-unique-proposal": "^0.3.4",
"classnames": "^2.5.1",
"copy-webpack-plugin": "^12.0.2",
Expand All @@ -34,15 +34,15 @@
"mobx-react-helper": "^0.3.1",
"mobx-restful": "^2.1.0-rc.1",
"mobx-restful-table": "^2.0.1",
"next": "^15.1.5",
"next": "^15.1.6",
"next-pwa": "^5.6.0",
"next-ssr-middleware": "^0.8.9",
"next-with-less": "^3.0.1",
"nextjs-google-analytics": "^2.3.7",
"open-react-map": "^0.8.1",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-bootstrap": "^2.10.7",
"react-bootstrap": "^2.10.8",
"react-dom": "^18.3.1",
"react-icalendar-link": "^3.0.2",
"react-leaflet": "^4.2.1",
Expand All @@ -59,7 +59,7 @@
"@eslint/compat": "^1.2.5",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@kaiyuanshe/kys-service": "^1.0.0-rc.1",
"@kaiyuanshe/kys-service": "^1.0.0-rc.2",
"@softonus/prettier-plugin-duplicate-remover": "^1.1.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/eslint__eslintrc": "^2.1.2",
Expand All @@ -72,7 +72,7 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"eslint": "^9.18.0",
"eslint-config-next": "^15.1.5",
"eslint-config-next": "^15.1.6",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-simple-import-sort": "^12.1.1",
Expand Down
77 changes: 37 additions & 40 deletions pages/election/[year]/vote.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Loading } from 'idea-react';
import { observable, when } from 'mobx';
import { computed, when } from 'mobx';
import { textJoin } from 'mobx-i18n';
import { observer } from 'mobx-react';
import dynamic from 'next/dynamic';
Expand All @@ -26,53 +26,48 @@ export default class ElectionVotePage extends Component<
> {
electionStore = new ElectionModel();

@observable
accessor code = '';
electionName = `KYS-administration-${this.props.route.params!.year}`;

@computed
get formData() {
const { currentVoteTicket } = this.electionStore;

if (!currentVoteTicket) return;

const data = Object.entries(currentVoteTicket).map(([name, value]) => [
`prefill_${name}`,
value!,
]);
return new URLSearchParams(data);
}

async componentDidMount() {
await when(() => !!userStore.session);

const { code } = await this.electionStore.createVoteTicket(
`KYS-administration-${this.props.route.params!.year}`,
);
this.code = code;
await this.electionStore.signVoteTicket(this.electionName);
}

renderVoteForm = (code: string) => (
<SessionBox autoCover>
<header className="my-4 text-center text-danger">
{code ? (
<>
<p>{t('vote_code_save_tips')}</p>
<pre className="d-inline-block p-3 bg-dark text-white rounded">
<code>{code}</code>
</pre>
</>
) : (
<p>{t('vote_code_fill_tips')}</p>
)}
</header>
<Row xs={1} sm={2}>
<Col>
<h2 className="text-center">{t('director_election_voting')}</h2>
<iframe
className="w-100 vh-100 border-0"
src={`${VoteForm.理事}?prefill_code=${code}`}
/>
</Col>
<Col>
<h2 className="text-center">{t('member_application_voting')}</h2>
<iframe
className="w-100 vh-100 border-0"
src={`${VoteForm.正式成员}?prefill_code=${code}`}
/>
</Col>
</Row>
</SessionBox>
renderVoteForm = (formData: URLSearchParams) => (
<Row xs={1} sm={2}>
<Col>
<h2 className="text-center">{t('director_election_voting')}</h2>
<iframe
className="w-100 vh-100 border-0"
src={`${VoteForm.理事}?${formData}`}
/>
</Col>
<Col>
<h2 className="text-center">{t('member_application_voting')}</h2>
<iframe
className="w-100 vh-100 border-0"
src={`${VoteForm.正式成员}?${formData}`}
/>
</Col>
</Row>
);

render() {
const { props, electionStore, code } = this;
const { props, electionStore, formData } = this;
const { year } = props.route.params!,
electionVoting = textJoin(t('election'), t('voting'));

Expand All @@ -92,7 +87,9 @@ export default class ElectionVotePage extends Component<

{loading && <Loading />}

{this.renderVoteForm(code)}
<SessionBox autoCover>
{formData && this.renderVoteForm(formData)}
</SessionBox>
</Container>
);
}
Expand Down
Loading

1 comment on commit 9607cd4

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for kaiyuanshe ready!

✅ Preview
https://kaiyuanshe-qv2lg867g-techquerys-projects.vercel.app

Built with commit 9607cd4.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.