Skip to content

Commit 30bab3d

Browse files
committed
(feat#3745): support ed25519 author signature validation on gov actions
1 parent b7deafb commit 30bab3d

File tree

11 files changed

+224
-3
lines changed

11 files changed

+224
-3
lines changed

govtool/backend/sql/list-proposals.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ SELECT
304304
COALESCE(cv.ccAbstainVotes, 0) cc_abstain_votes,
305305
prev_gov_action.index as prev_gov_action_index,
306306
encode(prev_gov_action_tx.hash, 'hex') as prev_gov_action_tx_hash,
307+
off_chain_vote_data.json ->> 'body' AS body,
307308
COALESCE(
308309
json_agg(
309310
json_build_object(
@@ -367,4 +368,5 @@ GROUP BY
367368
off_chain_vote_gov_action_data.title,
368369
off_chain_vote_gov_action_data.abstract,
369370
off_chain_vote_gov_action_data.motivation,
370-
off_chain_vote_gov_action_data.rationale;
371+
off_chain_vote_gov_action_data.rationale,
372+
off_chain_vote_data.json ->> 'body';

govtool/backend/src/VVA/API.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ proposalToResponse timeZone Types.Proposal {..} =
245245
proposalResponseCcAbstainVotes = proposalCcAbstainVotes,
246246
proposalResponsePrevGovActionIndex = proposalPrevGovActionIndex,
247247
proposalResponsePrevGovActionTxHash = HexText <$> proposalPrevGovActionTxHash,
248+
proposalResponseBody = proposalBody,
248249
proposalResponseAuthors = ProposalAuthors <$> proposalAuthors
249250
}
250251

govtool/backend/src/VVA/API/Types.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ data ProposalResponse
401401
, proposalResponseCcAbstainVotes :: Integer
402402
, proposalResponsePrevGovActionIndex :: Maybe Integer
403403
, proposalResponsePrevGovActionTxHash :: Maybe HexText
404+
, proposalResponseBody :: Maybe Text
404405
, proposalResponseAuthors :: Maybe ProposalAuthors
405406
}
406407
deriving (Generic, Show)

govtool/backend/src/VVA/Types.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ data Proposal
208208
, proposalCcAbstainVotes :: Integer
209209
, proposalPrevGovActionIndex :: Maybe Integer
210210
, proposalPrevGovActionTxHash :: Maybe Text
211+
, proposalBody :: Maybe Text
211212
, proposalAuthors :: Maybe Value
212213
}
213214
deriving (Show)
@@ -242,6 +243,7 @@ instance FromRow Proposal where
242243
<*> (floor @Scientific <$> field) -- proposalCcAbstainVotes
243244
<*> field -- prevGovActionIndex
244245
<*> field -- prevGovActionTxHash
246+
<*> field -- proposalBody
245247
<*> field -- proposalAuthors
246248

247249
data TransactionStatus = TransactionStatus

govtool/frontend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

govtool/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@intersect.mbo/pdf-ui": "1.0.1-alfa",
3333
"@mui/icons-material": "^5.14.3",
3434
"@mui/material": "^5.14.4",
35+
"@noble/ed25519": "^2.3.0",
3536
"@rollup/plugin-babel": "^6.0.4",
3637
"@rollup/pluginutils": "^5.1.0",
3738
"@sentry/react": "^7.77.0",

govtool/frontend/src/components/organisms/GovernanceActionDetailsCardData.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { useMemo, useState, Fragment } from "react";
1+
import { useMemo, useState, Fragment, useEffect } from "react";
22
import { Box, Tabs, Tab, styled, Skeleton } from "@mui/material";
33
import { useLocation } from "react-router-dom";
4+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
5+
import CancelIcon from "@mui/icons-material/Cancel";
46

57
import { CopyButton, ExternalModalButton, Tooltip, Typography } from "@atoms";
68
import {
@@ -23,6 +25,7 @@ import {
2325
getFullGovActionId,
2426
mapArrayToObjectByKeys,
2527
encodeCIP129Identifier,
28+
validateSignature,
2629
} from "@utils";
2730
import { MetadataValidationStatus, ProposalData } from "@models";
2831
import { GovernanceActionType } from "@/types/governanceAction";
@@ -77,6 +80,7 @@ export const GovernanceActionDetailsCardData = ({
7780
proposal: {
7881
abstract,
7982
authors,
83+
body,
8084
createdDate,
8185
createdEpochNo,
8286
details,
@@ -386,7 +390,21 @@ export const GovernanceActionDetailsCardData = ({
386390
placement="bottom-end"
387391
arrow
388392
>
389-
<span>{author.name}</span>
393+
<span
394+
style={{
395+
display: "inline-flex",
396+
alignItems: "center",
397+
gap: 2,
398+
}}
399+
>
400+
<AuthorSignatureStatus
401+
signature={author.signature}
402+
publicKey={author.publicKey}
403+
algorithm={author.witnessAlgorithm}
404+
body={body}
405+
/>
406+
{author.name}
407+
</span>
390408
</Tooltip>
391409
{idx < arr.length - 1 && <span>,&nbsp;</span>}
392410
</Fragment>
@@ -498,3 +516,48 @@ const HardforkDetailsTabContent = ({
498516
</Box>
499517
);
500518
};
519+
520+
const AuthorSignatureStatus = ({
521+
algorithm,
522+
publicKey,
523+
signature,
524+
body,
525+
}: {
526+
algorithm?: string;
527+
publicKey?: string;
528+
signature?: string;
529+
body?: string;
530+
}) => {
531+
const [isSignatureValid, setIsSignatureValid] = useState<boolean | null>(
532+
null,
533+
);
534+
535+
useEffect(() => {
536+
let cancelled = false;
537+
async function checkSignature() {
538+
const args = {
539+
message: body,
540+
algorithm,
541+
publicKey:
542+
"6A29D3C5C6280FBD9EF17EFFB29F8E8435D404BCF2FCED9336ABAEF1D06B62CB",
543+
signature:
544+
"23CFF1D1DA358AF8D835CD2F1F2A972D6E09DA5BD05C08E076017F83FEF1CAC082A853C12E7EA6BB44FC322809E6554ED69302CEE03DF6ABEF2C9D709C0E8906",
545+
};
546+
const result = await validateSignature(args);
547+
if (!cancelled) setIsSignatureValid(result);
548+
}
549+
checkSignature();
550+
return () => {
551+
cancelled = true;
552+
};
553+
}, [algorithm, body, publicKey, signature]);
554+
555+
if (isSignatureValid === null) {
556+
return <Skeleton variant="text" width={16} />;
557+
}
558+
return isSignatureValid ? (
559+
<CheckCircleIcon sx={{ color: "success.main", fontSize: 16 }} />
560+
) : (
561+
<CancelIcon sx={{ color: "error.main", fontSize: 16 }} />
562+
);
563+
};

govtool/frontend/src/models/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,9 @@ export type ProposalData = {
239239
witnessAlgorithm?: string;
240240
publicKey?: string;
241241
signature?: string;
242+
isSignatureValid?: boolean;
242243
}[];
244+
body?: string;
243245
} & SubmittedVotesData;
244246

245247
export type NewConstitutionAnchor = {

govtool/frontend/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ export * from "./uniqBy";
3737
export * from "./wait";
3838
export * from "./getBase64ImageDetails";
3939
export * from "./parseBoolean";
40+
export * from "./validateSignature";
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* We are switching to the "node" environment because jsdom does not provide a full WebCrypto API,
5+
* specifically window.crypto.subtle, which is required by @noble/ed25519 for signature
6+
* verification. Node.js (v16.8+) includes native crypto.subtle support, making it suitable for
7+
* cryptographic tests. Current browsers support window.crypto.subtle natively, so this issue only
8+
* affects the test environment. */
9+
10+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11+
import { validateSignature } from "../validateSignature";
12+
13+
describe("validateSignature", () => {
14+
const validEdInput = {
15+
// just for the sake of verification
16+
// privateKey: "A1CFF8671EDD404DCB27EC6101BD2B50FCB5B9B97790052B59BFC21647F1030B"
17+
message: `{"title": "Ryan Blockchain Ecosystem Budget - 100M ada", "abstract": "This is Ryan's proposal for a Blockchain Ecosystem Budget, which allocates 100 million ada to various projects and initiatives within the ecosystem.", "rationale": "uhhhhhhhhhhhhhhhh", "motivation": "Without funding Ryan cannot buy an island.", "references": [{"uri": "ipfs://xd", "@type": "Other", "label": "Lol"}]}`,
18+
publicKey:
19+
"6A29D3C5C6280FBD9EF17EFFB29F8E8435D404BCF2FCED9336ABAEF1D06B62CB",
20+
algorithm: "ed25519",
21+
signature:
22+
"23CFF1D1DA358AF8D835CD2F1F2A972D6E09DA5BD05C08E076017F83FEF1CAC082A853C12E7EA6BB44FC322809E6554ED69302CEE03DF6ABEF2C9D709C0E8906",
23+
};
24+
25+
beforeEach(() => {
26+
vi.spyOn(console, "error").mockImplementation(() => {});
27+
});
28+
29+
afterEach(() => {
30+
vi.restoreAllMocks();
31+
vi.clearAllMocks();
32+
});
33+
34+
it("returns false if signature is missing", async () => {
35+
const result = await validateSignature({
36+
...validEdInput,
37+
signature: undefined,
38+
});
39+
expect(result).toBe(false);
40+
});
41+
42+
it("returns false if publicKey is missing", async () => {
43+
const result = await validateSignature({
44+
...validEdInput,
45+
publicKey: undefined,
46+
});
47+
expect(result).toBe(false);
48+
});
49+
50+
it("returns false if algorithm is missing", async () => {
51+
const result = await validateSignature({
52+
...validEdInput,
53+
algorithm: undefined,
54+
});
55+
expect(result).toBe(false);
56+
});
57+
58+
it("returns false if message is missing", async () => {
59+
const result = await validateSignature({
60+
...validEdInput,
61+
message: undefined,
62+
});
63+
expect(result).toBe(false);
64+
});
65+
66+
it("returns false for unsupported algorithm", async () => {
67+
vi.spyOn(console, "error").mockImplementation(() => {});
68+
const result = await validateSignature({
69+
...validEdInput,
70+
algorithm: "rsa",
71+
});
72+
expect(result).toBe(false);
73+
expect(console.error).toHaveBeenCalledWith("Unsupported algorithm:", "rsa");
74+
});
75+
76+
describe("Ed25519 algorithm", () => {
77+
it("accepts 'Ed25519' (case-insensitive)", async () => {
78+
const result = await validateSignature({
79+
...validEdInput,
80+
algorithm: "Ed25519",
81+
});
82+
83+
expect(result).toBe(true);
84+
});
85+
86+
it("accepts 'ed25519' (case-insensitive)", async () => {
87+
const result = await validateSignature({
88+
...validEdInput,
89+
algorithm: "ed25519",
90+
});
91+
expect(result).toBe(true);
92+
});
93+
});
94+
});

0 commit comments

Comments
 (0)