diff --git a/models/Signature.ts b/models/Signature.ts new file mode 100644 index 0000000..db92b76 --- /dev/null +++ b/models/Signature.ts @@ -0,0 +1,64 @@ +import { observable } from 'mobx'; +import { BaseModel, persist, restore, toggle } from 'mobx-restful'; + +import { isServer } from './configuration'; + +export const buffer2hex = (buffer: ArrayBufferLike) => + Array.from(new Uint8Array(buffer), x => x.toString(16).padStart(2, '0')).join(''); + +export class SignatureModel extends BaseModel { + algorithm = { name: 'ECDSA', namedCurve: 'P-384', hash: { name: 'SHA-256' } }; + + @persist() + @observable + accessor privateKey: CryptoKey | undefined; + + @persist() + @observable + accessor publicKey = ''; + + @persist() + @observable + accessor signatureMap = {} as Record; + + restored = !isServer() && restore(this, 'Signature'); + + @toggle('uploading') + async makeKeyPair() { + 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 sign(value: string) { + await this.restored; + + let signature = this.signatureMap[value]; + + if (signature) return signature; + + if (!this.publicKey) await this.makeKeyPair(); + + const rawSignature = await crypto.subtle.sign( + this.algorithm, + this.privateKey!, + new TextEncoder().encode(value), + ); + signature = buffer2hex(rawSignature); + + this.signatureMap = { ...this.signatureMap, [value]: signature }; + + return signature; + } +} diff --git a/package.json b/package.json index ef4f837..a755174 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,11 @@ }, "dependencies": { "@giscus/react": "^3.1.0", - "@koa/router": "^15.2.0", + "@koa/router": "^15.3.0", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.1.4", - "core-js": "^3.47.0", + "@next/mdx": "^16.1.6", + "core-js": "^3.48.0", "echarts-jsx": "^0.6.0", "file-type": "^21.3.0", "idea-react": "^2.0.0-rc.13", @@ -28,24 +28,24 @@ "koa-jwt": "^4.0.4", "koajax": "^3.1.2", "license-filter": "^0.2.5", - "marked": "^17.0.1", + "marked": "^17.0.2", "mime": "^4.1.0", "mobx": "^6.15.0", "mobx-github": "^0.6.2", "mobx-i18n": "^0.7.2", - "mobx-lark": "^2.6.4", + "mobx-lark": "^2.6.5", "mobx-react": "^9.2.1", "mobx-react-helper": "^0.5.1", "mobx-restful": "^2.1.4", "mobx-restful-table": "^2.6.3", "mobx-strapi": "^0.8.1", - "next": "^16.1.4", + "next": "^16.1.6", "next-pwa": "^5.6.0", "next-ssr-middleware": "^1.1.0", "open-react-map": "^0.9.1", - "react": "^19.2.3", + "react": "^19.2.4", "react-bootstrap": "^2.10.10", - "react-dom": "^19.2.3", + "react-dom": "^19.2.4", "react-typed-component": "^1.0.6", "remark-frontmatter": "^5.0.0", "remark-mdx-frontmatter": "^5.2.0", @@ -53,39 +53,39 @@ "yaml": "^2.8.2" }, "devDependencies": { - "@babel/plugin-proposal-decorators": "^7.28.6", + "@babel/plugin-proposal-decorators": "^7.29.0", "@babel/plugin-transform-typescript": "^7.28.6", "@babel/preset-react": "^7.28.5", - "@cspell/eslint-plugin": "^9.6.0", - "@eslint/js": "^9.39.2", - "@next/eslint-plugin-next": "^16.1.4", + "@cspell/eslint-plugin": "^9.6.4", + "@eslint/js": "^10.0.1", + "@next/eslint-plugin-next": "^16.1.6", "@open-source-bazaar/china-ngo-database": "^0.6.0", "@softonus/prettier-plugin-duplicate-remover": "^1.1.2", - "@stylistic/eslint-plugin": "^5.7.0", + "@stylistic/eslint-plugin": "^5.8.0", "@types/eslint-config-prettier": "^6.11.3", "@types/jsonwebtoken": "^9.0.10", "@types/koa": "^3.0.1", "@types/next-pwa": "^5.6.9", - "@types/node": "^24.10.9", - "@types/react": "^19.2.8", + "@types/node": "^24.10.13", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "eslint": "^9.39.2", - "eslint-config-next": "^16.1.4", + "eslint": "^10.0.0", + "eslint-config-next": "^16.1.6", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", - "globals": "^17.0.0", + "globals": "^17.3.0", "husky": "^9.1.7", "jiti": "^2.6.1", "less": "^4.5.1", - "less-loader": "^12.3.0", + "less-loader": "^12.3.1", "lint-staged": "^16.2.7", "next-with-less": "^3.0.1", - "prettier": "^3.8.0", + "prettier": "^3.8.1", "prettier-plugin-css-order": "^2.2.0", - "sass": "^1.97.2", + "sass": "^1.97.3", "typescript": "~5.9.3", - "typescript-eslint": "^8.53.1" + "typescript-eslint": "^8.55.0" }, "resolutions": { "mobx-react-helper": "$mobx-react-helper", diff --git a/pages/api/signature/[...slug].ts b/pages/api/signature/[...slug].ts new file mode 100644 index 0000000..dea3351 --- /dev/null +++ b/pages/api/signature/[...slug].ts @@ -0,0 +1,24 @@ +import { createKoaRouter, withKoaRouter } from 'next-ssr-middleware'; + +import { safeAPI } from '../core'; + +export const config = { api: { bodyParser: false } }; + +const router = createKoaRouter(import.meta.url); + +router.post('/verification', safeAPI, async context => { + const { algorithm, publicKey, value, signature } = Reflect.get(context.request, 'body'); + + const rawAlgorithm = JSON.parse(atob(algorithm)), + rawPublicKey = JSON.parse(atob(publicKey)), + rawSignature = Buffer.from(signature, 'hex'), + encodedValue = new TextEncoder().encode(value); + + const key = await crypto.subtle.importKey('jwk', rawPublicKey, rawAlgorithm, true, ['verify']); + const verified = await crypto.subtle.verify(rawAlgorithm, key, rawSignature, encodedValue); + + context.status = verified ? 200 : 400; + context.body = {}; +}); + +export default withKoaRouter(router); diff --git a/pages/signature.tsx b/pages/signature.tsx new file mode 100644 index 0000000..0364a00 --- /dev/null +++ b/pages/signature.tsx @@ -0,0 +1,76 @@ +import { computed, observable } from 'mobx'; +import { textJoin } from 'mobx-i18n'; +import { observer } from 'mobx-react'; +import { ObservedComponent } from 'mobx-react-helper'; +import { compose, RouteProps, router } from 'next-ssr-middleware'; +import { Container } from 'react-bootstrap'; +import { buildURLData } from 'web-utility'; + +import { PageHead } from '../components/Layout/PageHead'; +import { i18n, I18nContext } from '../models/Translation'; +import { SignatureModel } from '../models/Signature'; + +export const getServerSideProps = compose(router); + +@observer +export default class SignaturePage extends ObservedComponent { + static contextType = I18nContext; + + @observable + accessor signatureStore = new SignatureModel(); + + @computed + get linkData() { + const { route } = this.observedProps; + const { valueName, algorithmName, publicKeyName, signatureName, value } = route.query, + { algorithm, publicKey } = this.signatureStore; + const signature = this.signatureStore.signatureMap[value + '']; + + return buildURLData({ + [valueName + '']: value, + [algorithmName + '']: btoa(JSON.stringify(algorithm)), + [publicKeyName + '']: publicKey, + [signatureName + '']: signature, + }); + } + + componentDidMount() { + const { value = '' } = this.props.route.query; + + if (!value) this.signatureStore.makeKeyPair(); + else this.signatureStore.sign(value + ''); + } + + render() { + const { t } = this.observedContext, + { value, iframeLink } = this.props.route.query; + + const title = value ? textJoin(t('sign'), value + '') : t('generate_key_pair'), + link = `${iframeLink}?${this.linkData}`; + + return ( + + + +

{title}

+ +
+
{t('signature_disclaimer')}
+
+            
+              
+                {link}
+              
+            
+          
+
+ +