diff --git a/.devcontainer/scripts/build-sequent-core.sh b/.devcontainer/scripts/build-sequent-core.sh index ef5c6aa5ced..f09d5274ee8 100755 --- a/.devcontainer/scripts/build-sequent-core.sh +++ b/.devcontainer/scripts/build-sequent-core.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/bash set -euo pipefail # SPDX-FileCopyrightText: 2025 Sequent Tech @@ -9,7 +9,8 @@ set -euo pipefail # flag, which is not supported when compiling C code for WebAssembly targets # (used by the ring cryptographic library): export NIX_HARDENING_ENABLE="" -export CFLAGS_wasm32_unknown_unknown="-O3 -ffunction-sections -fdata-sections -fno-exceptions"; +# Append optimization flags to existing CFLAGS (preserving include paths from flake.nix) +export CFLAGS_wasm32_unknown_unknown="${CFLAGS_wasm32_unknown_unknown} -O3 -ffunction-sections -fdata-sections -fno-exceptions" TARGET_DIR=/workspaces/step/packages/sequent-core cd "$TARGET_DIR" @@ -22,7 +23,7 @@ wasm-pack --version which wasm-bindgen wasm-bindgen --version -wasm-pack build --mode no-install --out-name index --release --target web --features=wasmtest +wasm-pack build --mode no-install --out-name index --release --target web --features=wasmtest,default_features wasm-pack -v pack . 2>&1 | tee output.log cd .. diff --git a/.github/workflows/build_wasm.yml b/.github/workflows/build_wasm.yml index b10199e99e5..9792a9e0d4b 100644 --- a/.github/workflows/build_wasm.yml +++ b/.github/workflows/build_wasm.yml @@ -52,9 +52,14 @@ jobs: - name: Check that WASM build works working-directory: packages/sequent-core run: | - nix develop --command wasm-pack build --mode no-install --out-name index --release --target web --features=wasmtest + nix develop --command wasm-pack build --mode no-install --out-name index --release --target web --features=wasmtest,default_features - name: Check that WASM pack works working-directory: packages/sequent-core run: | nix develop --command wasm-pack -v pack . + + - name: Check that WASM tests work + working-directory: packages/sequent-core + run: | + nix develop --command wasm-pack test --release --firefox --headless -- --features=wasmtest,default_features diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1630ca29b8e..70d62c330b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: - service: immu-board - service: immudb-rs - service: sequent-core - extra: --features keycloak + extra: --features keycloak,default_features - service: velvet - service: windmill - service: wrap-map-err diff --git a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md index a20ed5fe9ad..4867cb0332c 100644 --- a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md +++ b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md @@ -108,8 +108,9 @@ Provide documents that voters can access in the Voting Portal. Configure advanced system behaviors for this Election Event. - **Contest Encryption Policy**: - - **Single Contests**: Encrypt contests individually. + - **Single Contest**: Encrypt contests individually. - **Multiple Contests**: Encrypt multiple contests together to enable ballot-level audit. + - **Unencrypted Single Contest (Plaintext)**: Run elections without encryption. This option is suitable for non-confidential voting scenarios where encryption is not required. When this policy is selected, key ceremonies are not necessary, and the tally process is simplified. - **Lockdown Status**: When enabled, no changes can be made to this Election Event. This action is irreversible. - **Voting Portal Countdown Policy**: - Define the session timeout duration in seconds. diff --git a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/07-election_management_election-event_keys.md b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/07-election_management_election-event_keys.md index 0b1c64f9b93..779139b5731 100644 --- a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/07-election_management_election-event_keys.md +++ b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/07-election_management_election-event_keys.md @@ -11,6 +11,8 @@ SPDX-License-Identifier: AGPL-3.0-only The Key Ceremony establishes the collective private key used to decrypt votes and publishes a corresponding public key for voters to encrypt their ballots. During this ceremony, trustees collaboratively generate fragments of the private key, ensuring no single party holds the full secret. Each trustee downloads, securely backs up, and verifies their key fragment. Only after all trustees complete these steps can the election proceed to stages such as ballot publication, voting, and tallying. This distributed approach guarantees that votes encrypted under the shared public key remain confidential and tamper-resistant, and that the combined private key—reconstructed only when a threshold of trustees collaborates—preserves integrity and availability of decryption throughout the election lifecycle. +> **Note for Unencrypted Elections**: If your Election Event is configured with the **Unencrypted Single Contest (Plaintext)** encryption policy, keys are not required. The Key Ceremony is only necessary for encrypted elections. You can proceed directly to publishing and tallying without performing a Key Ceremony. + ## Opening Key Distribution Ceremony diff --git a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/08-01-election_management_election-event_tally.md b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/08-01-election_management_election-event_tally.md index 50a43db8e01..0462e9fa0f3 100644 --- a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/08-01-election_management_election-event_tally.md +++ b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/08-01-election_management_election-event_tally.md @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only ## Introduction The Tally section covers the essential procedures required to consolidate resultd after voting has concluded. Election Board members are guided through starting the tally ceremony, verifying key fragments, running the tally, and reviewing results. By following these instructions, the post-election phase is handled with integrity and precision, ensuring that results are verified and published correctly and that the process remains transparent and trustworthy. -## Closing Key Ceremony -During the Closing Key Ceremony for an Election Event, trustees use their cryptographic key fragments to decrypt the final tally: +## Closing Key Ceremony (Encrypted Elections Only) +During the Closing Key Ceremony for an **encrypted** Election Event, trustees use their cryptographic key fragments to decrypt the final tally: - **Retrieve stored key fragments:** Each trustee securely retrieves their previously backed-up fragment of the private key. - **Verify integrity and functionality:** Trustees perform cryptographic tests or audits to confirm each fragment is unaltered and functional. @@ -22,24 +22,41 @@ During the Closing Key Ceremony for an Election Event, trustees use their crypto This ceremony ensures that only a quorum of trustees can reconstruct the private key for decryption, preserving security throughout the election lifecycle. +> **Note**: This ceremony step is automatically skipped for Election Events configured with the **Unencrypted Single Contest (Plaintext)** encryption policy. + ## Prerequisites for Tally Before initiating the tally process: -1. **Opening Key Ceremony completed:** All key fragments have been generated and verified. +1. **Opening Key Ceremony completed (for encrypted elections):** All key fragments have been generated and verified. 2. **Voting phase concluded (optional):** Votes may or may not have been cast; starting a tally with no votes is technically possible but yields empty results. -> Note: According to the Election Event Keys/Tally Ceremonies Policy, if the Key Ceremony is set to 'Automatic,' the Tally Ceremony will also be automated, and no trustee action is required. +> **Note for Automated Ceremonies**: According to the Election Event Keys/Tally Ceremonies Policy, if the Key Ceremony is set to 'Automatic,' the Tally Ceremony will also be automated, and no trustee action is required. + +> **Note for Unencrypted Elections**: If your Election Event is configured with the **Unencrypted Single Contest (Plaintext)** encryption policy, the Key Ceremony step is not required. The tally process will automatically skip the ceremony phase and proceed directly to tallying the votes. ## Starting the Tally Process + +### For Encrypted Elections 1. Navigate to the relevant **Election Event** in the Administration Portal. 2. Go to the **Tally** tab. 3. Click **Start Tally Ceremony**. 4. Select one or more Elections to include in this tally. -5. Confirm to proceed; the system will notify trustees to verify their key fragments. -6. Trustees verify their fragments as prompted; once the threshold of fragments is verified, the tally begins automatically. -7. Wait for the system to finalize the tally. A success notification appears when complete. +5. Select the Key Ceremony to use for decryption. +6. Confirm to proceed; the system will notify trustees to verify their key fragments. +7. Trustees verify their fragments as prompted; once the threshold of fragments is verified, the tally begins automatically. +8. Wait for the system to finalize the tally. A success notification appears when complete. + +### For Unencrypted Elections (Plaintext) +1. Navigate to the relevant **Election Event** in the Administration Portal. +2. Go to the **Tally** tab. +3. Click **Start Tally Ceremony**. +4. Select one or more Elections to include in this tally. +5. Confirm to proceed; the system will **automatically start the tally** without requiring trustee verification. +6. Wait for the system to finalize the tally. A success notification appears when complete. + +## Trustee Key Verification for Tally (Encrypted Elections Only) +This section applies only to **encrypted** elections. For unencrypted elections, this step is automatically skipped. -## Trustee Key Verification for Tally 1. Log in to the Administration Portal and open the relevant Election Event. 2. Go to the **Tally** tab. 3. Click the Key Action invitation or the key icon next to the Tally ceremony. diff --git a/hasura/migrations/backend-db/1762526933099_alter_table_sequent_backend_tally_session_alter_column_keys_ceremony_id/down.sql b/hasura/migrations/backend-db/1762526933099_alter_table_sequent_backend_tally_session_alter_column_keys_ceremony_id/down.sql new file mode 100644 index 00000000000..21c8baf8c68 --- /dev/null +++ b/hasura/migrations/backend-db/1762526933099_alter_table_sequent_backend_tally_session_alter_column_keys_ceremony_id/down.sql @@ -0,0 +1 @@ +alter table "sequent_backend"."tally_session" alter column "keys_ceremony_id" set not null; diff --git a/hasura/migrations/backend-db/1762526933099_alter_table_sequent_backend_tally_session_alter_column_keys_ceremony_id/up.sql b/hasura/migrations/backend-db/1762526933099_alter_table_sequent_backend_tally_session_alter_column_keys_ceremony_id/up.sql new file mode 100644 index 00000000000..4252b4cdbf9 --- /dev/null +++ b/hasura/migrations/backend-db/1762526933099_alter_table_sequent_backend_tally_session_alter_column_keys_ceremony_id/up.sql @@ -0,0 +1 @@ +alter table "sequent_backend"."tally_session" alter column "keys_ceremony_id" drop not null; diff --git a/packages/admin-portal/graphql.schema.json b/packages/admin-portal/graphql.schema.json index ab1b071bacd..030f77e4f73 100644 --- a/packages/admin-portal/graphql.schema.json +++ b/packages/admin-portal/graphql.schema.json @@ -116895,13 +116895,9 @@ "description": null, "args": [], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "uuid", - "ofType": null - } + "kind": "SCALAR", + "name": "uuid", + "ofType": null }, "isDeprecated": false, "deprecationReason": null diff --git a/packages/admin-portal/rust/sequent-core-0.1.0.tgz b/packages/admin-portal/rust/sequent-core-0.1.0.tgz index 53f0a003975..8e4b25bca15 100644 Binary files a/packages/admin-portal/rust/sequent-core-0.1.0.tgz and b/packages/admin-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/admin-portal/src/gql/graphql.ts b/packages/admin-portal/src/gql/graphql.ts index c3ea1bd4dd2..94dffc8463b 100644 --- a/packages/admin-portal/src/gql/graphql.ts +++ b/packages/admin-portal/src/gql/graphql.ts @@ -16352,7 +16352,7 @@ export type Sequent_Backend_Tally_Session = { execution_status?: Maybe; id: Scalars['uuid']['output']; is_execution_completed: Scalars['Boolean']['output']; - keys_ceremony_id: Scalars['uuid']['output']; + keys_ceremony_id?: Maybe; labels?: Maybe; last_updated_at?: Maybe; permission_label?: Maybe>; diff --git a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventKeys.tsx b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventKeys.tsx index 453721d89bb..bb47962c01c 100644 --- a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventKeys.tsx +++ b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventKeys.tsx @@ -48,6 +48,7 @@ import {useAliasRenderer} from "@/hooks/useAliasRenderer" import {useKeysPermissions} from "./useKeysPermissions" import {TrusteeItems} from "@/components/TrusteeItems" import {StyledChip} from "@/components/StyledChip" +import {EElectionEventContestEncryptionPolicy} from "@sequentech/ui-core" const NotificationLink = styled("span")` text-decoration: underline; @@ -129,6 +130,11 @@ export const EditElectionEventKeys: React.FC = (prop const isTrustee = authContext.hasRole(IPermissions.TRUSTEE_CEREMONY) const {globalSettings} = useContext(SettingsContext) + // Check if the election policy is Plaintext + const isUnencrypted = + electionEvent?.presentation?.contest_encryption_policy === + EElectionEventContestEncryptionPolicy.PLAINTEXT + const {data: keysCeremonies} = useQuery(LIST_KEYS_CEREMONY, { variables: { tenantId: tenantId, @@ -176,7 +182,8 @@ export const EditElectionEventKeys: React.FC = (prop const CreateButton = () => ( @@ -239,16 +247,18 @@ export const ListTally: React.FC = () => { const Empty = () => ( - {canCreateCeremony && !isKeyCeremonyFinished && ( + {canCreateCeremony && !isKeyCeremonyFinished && !isUnencryptedPolicy && ( {t("electionEventScreen.tally.notify.noKeysTally")} )} - {canCreateCeremony && isKeyCeremonyFinished && !isPublished && ( - - {t("electionEventScreen.tally.notify.noPublication")} - - )} + {canCreateCeremony && + (isKeyCeremonyFinished || isUnencryptedPolicy) && + !isPublished && ( + + {t("electionEventScreen.tally.notify.noPublication")} + + )} {t("electionEventScreen.tally.emptyHeader")} @@ -442,10 +452,12 @@ export const ListTally: React.FC = () => { filter={{ tenant_id: tenantId || undefined, election_event_id: electionEventRecord?.id || undefined, - keys_ceremony_id: { - format: "hasura-raw-query", - value: {_in: keysCeremonyIds}, - }, + keys_ceremony_id: isUnencryptedPolicy + ? undefined + : { + format: "hasura-raw-query", + value: {_in: keysCeremonyIds}, + }, }} storeKey={false} filters={Filters} diff --git a/packages/admin-portal/src/resources/Tally/TallyCeremony.tsx b/packages/admin-portal/src/resources/Tally/TallyCeremony.tsx index b40da03c4c9..3c7b3921960 100644 --- a/packages/admin-portal/src/resources/Tally/TallyCeremony.tsx +++ b/packages/admin-portal/src/resources/Tally/TallyCeremony.tsx @@ -48,6 +48,7 @@ import { EAllowTally, EElectionEventCeremoniesPolicy, EInitializeReportPolicy, + EElectionEventContestEncryptionPolicy, EInitReport, EVotingStatus, isArray, @@ -359,6 +360,12 @@ export const TallyCeremony: React.FC = () => { currentKeysCeremony?.settings?.policy === EElectionEventCeremoniesPolicy.AUTOMATED_CEREMONIES + const isUnencryptedElection = + electionEvent?.presentation?.contest_encryption_policy === + EElectionEventContestEncryptionPolicy.PLAINTEXT + + const shouldSkipCeremony = isAutomatedCeremony || isUnencryptedElection + useEffect(() => { if (tallySession?.is_execution_completed && !isTallyCompleted) { // Only mark as completed if we have the resultsEventId @@ -383,6 +390,22 @@ export const TallyCeremony: React.FC = () => { useEffect(() => { if (tallySession) { setTally(tallySession) + + const localCurrentKeysCeremony = sortedKeysCeremonies.find( + (ceremony: any) => tallySession?.keys_ceremony_id === ceremony.id + ) + const localIsAutomatedCeremony = + electionEvent?.presentation?.ceremonies_policy === + EElectionEventCeremoniesPolicy.AUTOMATED_CEREMONIES && + localCurrentKeysCeremony?.settings?.policy === + EElectionEventCeremoniesPolicy.AUTOMATED_CEREMONIES + + const localIsPlaintextElection = + electionEvent?.presentation?.contest_encryption_policy === + EElectionEventContestEncryptionPolicy.PLAINTEXT + + const localShouldSkipCeremony = localIsAutomatedCeremony || localIsPlaintextElection + if (!tallyId && tallySession.execution_status !== ITallyExecutionStatus.CANCELLED) { setPage(WizardSteps.Start) return @@ -392,6 +415,43 @@ export const TallyCeremony: React.FC = () => { tallySession.execution_status === ITallyExecutionStatus.CONNECTED || tallySession.execution_status === ITallyExecutionStatus.CANCELLED ) { + if ( + localShouldSkipCeremony && + tallySession.execution_status === ITallyExecutionStatus.CONNECTED && + !isConfirming // Prevent re-triggering if already in progress + ) { + setIsConfirming(true) // Use loading state + + // Use an async IIFE to await the mutation and ensure errors are handled + void (async () => { + try { + const {data: nextStatus, errors} = await UpdateTallyCeremonyMutation({ + variables: { + election_event_id: record?.id, + tally_session_id: tallySession.id, + status: ITallyExecutionStatus.IN_PROGRESS, + }, + }) + + if (errors) { + notify(t("tally.startTallyError"), {type: "error"}) + console.error("Auto-start tally failed", errors) + return + } + + // If successful, refetch to pick up updated status + refetchTallySession() + } catch (error) { + notify(t("tally.startTallyError"), {type: "error"}) + console.error("Auto-start tally failed", error) + } finally { + setIsConfirming(false) // Always reset loading state + } + })() + + return // Wait for mutation to cause refetch + } + setPage(WizardSteps.Ceremony) return } @@ -458,7 +518,14 @@ export const TallyCeremony: React.FC = () => { ) }) || false ) - }, [elections, currentKeysCeremony, isAutomatedCeremony, selectedElections]) + }, [elections, currentKeysCeremony, shouldSkipCeremony, selectedElections]) + + // This useEffect clears the keysCeremonyId when the election is unencrypted *** + useEffect(() => { + if (isUnencryptedElection) { + setKeysCeremonyId(undefined) + } + }, [isUnencryptedElection]) useEffect(() => { if (page === WizardSteps.Start && creatingType !== ETallyType.INITIALIZATION_REPORT) { @@ -469,16 +536,34 @@ export const TallyCeremony: React.FC = () => { let newIsButtonDisabled = (page === WizardSteps.Start && selectedElections?.length === 0 ? true : false) || !is_published + + // This is the key change: + // Check for automatic ceremony *only* if it's an AUTOMATED ceremony. + // If it's PLAINTEXT (isUnencryptedElection = true), this check will be false, + // allowing the button to be enabled without a key. let isAutomaticCeremonyTallyNotAllowed = isAutomatedCeremony && !isAutomaticTallyAllowed - setIsButtonDisabled(newIsButtonDisabled || isAutomaticCeremonyTallyNotAllowed) + const isDisabled = newIsButtonDisabled || isAutomaticCeremonyTallyNotAllowed + setIsButtonDisabled(isDisabled) + if (isAutomaticCeremonyTallyNotAllowed) { setNextDisabledReason(t("electionEventScreen.tally.notify.ceremonyDisabled")) } else if (newIsButtonDisabled) { setNextDisabledReason(t("electionEventScreen.tally.notify.startDisabled")) + } else { + setNextDisabledReason(null) // Clear reason if enabled } } - }, [selectedElections, isAutomatedCeremony, isAutomaticTallyAllowed]) + }, [ + page, + creatingType, + elections, + selectedElections, + isAutomaticTallyAllowed, + isAutomatedCeremony, + isUnencryptedElection, + t, + ]) const isInitAllowed = useMemo(() => { return ( @@ -791,7 +876,7 @@ export const TallyCeremony: React.FC = () => { const breadCrumbSteps = () => { let steps = ["tally.breadcrumbSteps.start"] - if (!isAutomatedCeremony) { + if (!shouldSkipCeremony) { steps.push("tally.breadcrumbSteps.ceremony") } steps.push("tally.breadcrumbSteps.tally") @@ -806,7 +891,7 @@ export const TallyCeremony: React.FC = () => { 0 ? page - 1 : page} // skipped ceremony page number + selected={shouldSkipCeremony && page > 0 ? page - 1 : page} variant={BreadCrumbStepsVariant.Circle} colorPreviousSteps={true} /> @@ -827,8 +912,7 @@ export const TallyCeremony: React.FC = () => { ) : null} {page === WizardSteps.Start && ( <> - {/* - This code snippet determines whether the "Next" button should be + {/* This code snippet determines whether the "Next" button should be disabled on the Start page of the wizard. The button is disabled if: 1. The current page is the Start page and no elections are selected. 2. The elections are not published. @@ -855,38 +939,41 @@ export const TallyCeremony: React.FC = () => { keysCeremonyId={keysCeremonyId ?? null} tallySession={tallySession} /> - - - - - + {/* Hide Key Selection for Plaintext events */} + {!isUnencryptedElection && ( + + + + + + )} )} - {!isAutomatedCeremony && page === WizardSteps.Ceremony && ( + {!shouldSkipCeremony && page === WizardSteps.Ceremony && ( <> - {/* - This code snippet determines whether the "Next" button should be + {/* This code snippet determines whether the "Next" button should be disabled on the Ceremony page of the wizard. The button is disabled if: 1. The tally object's execution_status is not equal to ITallyExecutionStatus.CONNECTED. 2. The isStartAllowed variable is false. @@ -1182,7 +1269,7 @@ export const TallyCeremony: React.FC = () => { <> {page === WizardSteps.Start ? creatingType === ETallyType.ELECTORAL_RESULTS - ? isAutomatedCeremony + ? shouldSkipCeremony ? t("tally.common.start") : t("tally.common.ceremony") : t("tally.common.initialization") @@ -1220,7 +1307,7 @@ export const TallyCeremony: React.FC = () => { ok={String(t("tally.common.dialog.ok"))} cancel={String(t("tally.common.dialog.cancel"))} title={ - isAutomatedCeremony + shouldSkipCeremony ? t("tally.common.dialog.tallyTitle") : t("tally.common.dialog.title") } @@ -1237,7 +1324,7 @@ export const TallyCeremony: React.FC = () => { // Don't enable the button again because it is handled in the effect when the page changes }} > - {isAutomatedCeremony + {shouldSkipCeremony ? t("tally.common.dialog.startAutomatedTallyMessage") : t("tally.common.dialog.message")} diff --git a/packages/admin-portal/src/translations/cat.ts b/packages/admin-portal/src/translations/cat.ts index 23622940185..1a1b3142d31 100644 --- a/packages/admin-portal/src/translations/cat.ts +++ b/packages/admin-portal/src/translations/cat.ts @@ -322,6 +322,7 @@ const catalanTranslation: TranslationType = { options: { "single-contest": "Concurs únic", "multiple-contests": "Diversos concursos", + "plaintext": "Concurs únic sense encriptar", }, policyLabel: "Política de xifrat de concurs", }, @@ -420,6 +421,7 @@ const catalanTranslation: TranslationType = { notify: { participateNow: "Ha estat convidat a participar a una Cerimònia de Claus. Si us plau <1>feu clic a continuació en l'acció de clau de la cerimònia per participar.", + plaintextNoKeys: "No es requereixen claus per a les eleccions sense encriptar.", }, }, tabs: { diff --git a/packages/admin-portal/src/translations/en.ts b/packages/admin-portal/src/translations/en.ts index fe56d5beea1..3c6f607979c 100644 --- a/packages/admin-portal/src/translations/en.ts +++ b/packages/admin-portal/src/translations/en.ts @@ -323,6 +323,7 @@ const englishTranslation = { options: { "single-contest": "Single Contest", "multiple-contests": "Multiple Contests", + "plaintext": "Unencrypted Single Contest", }, policyLabel: "Contest encryption policy", }, @@ -419,6 +420,7 @@ const englishTranslation = { notify: { participateNow: "You have been invited to participate in a Keys ceremony. Please <1>click on the ceremony's Key Action to participate.", + plaintextNoKeys: "Keys are not required for unencrypted elections.", }, }, tabs: { diff --git a/packages/admin-portal/src/translations/es.ts b/packages/admin-portal/src/translations/es.ts index 6280bbcad03..62d2b363aca 100644 --- a/packages/admin-portal/src/translations/es.ts +++ b/packages/admin-portal/src/translations/es.ts @@ -323,6 +323,7 @@ const spanishTranslation: TranslationType = { options: { "single-contest": "Concurso único", "multiple-contests": "Varios concursos", + "plaintext": "Concurso único sin encriptar", }, policyLabel: "Política de cifrado de concurso", }, @@ -421,6 +422,7 @@ const spanishTranslation: TranslationType = { notify: { participateNow: "Ha sido invitado a participar a una Ceremonia de Claves. Por favor <1>haz clic abajo en la acción de llave de la ceremonia para participar.", + plaintextNoKeys: "No se requieren claves para las elecciones no encriptadas.", }, }, tabs: { diff --git a/packages/admin-portal/src/translations/eu.ts b/packages/admin-portal/src/translations/eu.ts index a5f2db8b26c..afaa9b11436 100644 --- a/packages/admin-portal/src/translations/eu.ts +++ b/packages/admin-portal/src/translations/eu.ts @@ -322,6 +322,7 @@ const basqueTranslation: TranslationType = { options: { "single-contest": "Lehiaketa Bakarra", "multiple-contests": "Lehiaketa Anitzak", + "plaintext": "Zifratu gabeko lehiaketa bakarra", }, policyLabel: "Lehiaketa zifratze politika", }, @@ -420,6 +421,7 @@ const basqueTranslation: TranslationType = { notify: { participateNow: "Giltzen zeremonoia batean parte hartzeko gonbidatu zaituzte. Mesedez <1>sakatu zeremoniaren Giltza Ekintzaren parte hartzeko.", + plaintextNoKeys: "Ez da gakorik behar enkriptatu gabeko hauteskundeetarako.", }, }, tabs: { diff --git a/packages/admin-portal/src/translations/fr.ts b/packages/admin-portal/src/translations/fr.ts index 0076713f435..6a977176e97 100644 --- a/packages/admin-portal/src/translations/fr.ts +++ b/packages/admin-portal/src/translations/fr.ts @@ -322,6 +322,7 @@ const frenchTranslation: TranslationType = { options: { "single-contest": "Concours unique", "multiple-contests": "Plusieurs concours", + "plaintext": "Concours unique non chiffré", }, policyLabel: "Politique de chiffrement de concours", }, @@ -420,6 +421,8 @@ const frenchTranslation: TranslationType = { notify: { participateNow: "Vous avez été invité à participer à une Cérémonie de Clés. Veuillez <1>cliquer ci-dessous sur l'action de clé de la cérémonie pour participer.", + plaintextNoKeys: + "Les clés ne sont pas requises pour les élections non chiffrées.", }, }, tabs: { diff --git a/packages/admin-portal/src/translations/gl.ts b/packages/admin-portal/src/translations/gl.ts index 2b479976251..e6bfd676b47 100644 --- a/packages/admin-portal/src/translations/gl.ts +++ b/packages/admin-portal/src/translations/gl.ts @@ -322,6 +322,7 @@ const galegoTranslation: TranslationType = { options: { "single-contest": "Concurso único", "multiple-contests": "Varios concursos", + "plaintext": "Concurso único non cifrado", }, policyLabel: "Política de cifrado de concurso", }, @@ -420,6 +421,7 @@ const galegoTranslation: TranslationType = { notify: { participateNow: "Fostes invitado a participar nunha Cerimonia de Chaves. Por favor, <1>fai clic na Acción de Chave da cerimonia para participar.", + plaintextNoKeys: "Non se requiren claves para as eleccións non cifradas.", }, }, tabs: { diff --git a/packages/admin-portal/src/translations/nl.ts b/packages/admin-portal/src/translations/nl.ts index 2680e57b0fd..71edb95802d 100644 --- a/packages/admin-portal/src/translations/nl.ts +++ b/packages/admin-portal/src/translations/nl.ts @@ -321,6 +321,7 @@ const dutchTranslation: TranslationType = { options: { "single-contest": "Enkele Verkiezing", "multiple-contests": "Meerdere Verkiezingen", + "plaintext": "Enkele Niet-versleutelde Verkiezing", }, policyLabel: "Encryptiebeleid verkiezingen", }, @@ -418,6 +419,8 @@ const dutchTranslation: TranslationType = { notify: { participateNow: "U bent uitgenodigd om deel te nemen aan een sleutelceremonie. Gelieve op de Sleutelactie van de ceremonie te klikken om deel te nemen.", + plaintextNoKeys: + "Er zijn geen sleutels vereist voor niet-versleutelde verkiezingen.", }, }, tabs: { diff --git a/packages/admin-portal/src/translations/tl.ts b/packages/admin-portal/src/translations/tl.ts index 8cd5df426cb..010a55d98e9 100644 --- a/packages/admin-portal/src/translations/tl.ts +++ b/packages/admin-portal/src/translations/tl.ts @@ -323,6 +323,7 @@ const tagalogTranslation: TranslationType = { options: { "single-contest": "Isang Paligsahan", "multiple-contests": "Maraming Paligsahan", + "plaintext": "Iisang Paligsahang Hindi Naka-encrypt", }, policyLabel: "Patakaran sa Pag-encode ng Paligsahan", }, @@ -419,6 +420,8 @@ const tagalogTranslation: TranslationType = { notify: { participateNow: "Naanyayahan kang makibahagi sa seremonya ng mga susi. Mangyaring <1>i-click ang Aksyon ng Key ng seremonya upang makilahok.", + plaintextNoKeys: + "Hindi kailangan ng mga susi para sa mga halalang hindi naka-encrypt.", }, }, tabs: { diff --git a/packages/b3/src/client/pgsql.rs b/packages/b3/src/client/pgsql.rs index 198ad3eb5e4..48e0132256e 100644 --- a/packages/b3/src/client/pgsql.rs +++ b/packages/b3/src/client/pgsql.rs @@ -789,7 +789,9 @@ async fn insert_ballots( board_name: &str, ballots: Message, ) -> Result<()> { - if ballots.statement.get_kind() != StatementType::Ballots { + if ballots.statement.get_kind() != StatementType::Ballots + && ballots.statement.get_kind() != StatementType::Plaintexts + { return Err(anyhow!("Expected message to be Ballots")); } diff --git a/packages/ballot-verifier/graphql.schema.json b/packages/ballot-verifier/graphql.schema.json index ab1b071bacd..030f77e4f73 100644 --- a/packages/ballot-verifier/graphql.schema.json +++ b/packages/ballot-verifier/graphql.schema.json @@ -116895,13 +116895,9 @@ "description": null, "args": [], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "uuid", - "ofType": null - } + "kind": "SCALAR", + "name": "uuid", + "ofType": null }, "isDeprecated": false, "deprecationReason": null diff --git a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz index 53f0a003975..8e4b25bca15 100644 Binary files a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz and b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/ballot-verifier/src/gql/graphql.ts b/packages/ballot-verifier/src/gql/graphql.ts index 404cd4553e5..7c9cfaafa73 100644 --- a/packages/ballot-verifier/src/gql/graphql.ts +++ b/packages/ballot-verifier/src/gql/graphql.ts @@ -15855,7 +15855,7 @@ export type Sequent_Backend_Tally_Session = { execution_status?: Maybe id: Scalars["uuid"]["output"] is_execution_completed: Scalars["Boolean"]["output"] - keys_ceremony_id: Scalars["uuid"]["output"] + keys_ceremony_id?: Maybe labels?: Maybe last_updated_at?: Maybe permission_label?: Maybe> diff --git a/packages/ballot-verifier/src/screens/HomeScreen.tsx b/packages/ballot-verifier/src/screens/HomeScreen.tsx index b1473021116..8dcdb281f05 100644 --- a/packages/ballot-verifier/src/screens/HomeScreen.tsx +++ b/packages/ballot-verifier/src/screens/HomeScreen.tsx @@ -19,7 +19,13 @@ import { theme, Dialog, } from "@sequentech/ui-essentials" -import {IAuditableBallot, IAuditableMultiBallot, IAuditableSingleBallot} from "@sequentech/ui-core" +import { + IAuditableBallot, + IAuditableMultiBallot, + IAuditableSingleBallot, + IAuditablePlaintextBallot, + EElectionEventContestEncryptionPolicy, +} from "@sequentech/ui-core" import {useNavigate} from "react-router-dom" import {Box} from "@mui/material" import {IBallotService, IConfirmationBallot} from "../services/BallotService" @@ -159,6 +165,8 @@ export const HomeScreen: React.FC = ({ }, [dataBallotStyles]) const handleAuditableBallot = (auditableBallot: IAuditableBallot | null) => { + const encryptionPolicy = + auditableBallot?.config.election_event_presentation?.contest_encryption_policy let isMultiContest = false let decodedBallot = null try { @@ -185,9 +193,20 @@ export const HomeScreen: React.FC = ({ setConfirmationBallot(null) return } - let ballotHash = isMultiContest - ? ballotService.hashMultiBallot(auditableBallot as IAuditableMultiBallot) - : ballotService.hashBallot512(auditableBallot as IAuditableSingleBallot) + let ballotHash = (function () { + switch (encryptionPolicy) { + case EElectionEventContestEncryptionPolicy.SINGLE_CONTEST: + return ballotService.hashBallot512(auditableBallot as IAuditableSingleBallot) + case EElectionEventContestEncryptionPolicy.MULTIPLE_CONTESTS: + return ballotService.hashMultiBallot(auditableBallot as IAuditableMultiBallot) + case EElectionEventContestEncryptionPolicy.PLAINTEXT: + return ballotService.hashPlaintextBallot( + auditableBallot as IAuditablePlaintextBallot + ) + default: + throw new Error("Failed to hash ballot") + } + })() if ( auditableBallot?.voter_ballot_signature !== undefined && diff --git a/packages/ballot-verifier/src/services/BallotService.ts b/packages/ballot-verifier/src/services/BallotService.ts index a3e03205dcb..c36a6f99ec7 100644 --- a/packages/ballot-verifier/src/services/BallotService.ts +++ b/packages/ballot-verifier/src/services/BallotService.ts @@ -4,8 +4,10 @@ import { hashMultiBallot, hashBallot512, + hashPlaintextBallot, decodeAuditableBallot, decodeAuditableMultiBallot, + decodeAuditablePlaintextBallot, getLayoutProperties, getPoints, generateSampleAuditableBallot, @@ -16,11 +18,13 @@ import { IContest, IAuditableSingleBallot, IAuditableMultiBallot, + IAuditablePlaintextBallot, IContestLayoutProperties, verifyBallotSignature, verifyMultiBallotSignature, ICountingAlgorithm, isPreferential, + verifyPlaintextBallotSignature, } from "@sequentech/ui-core" export interface IConfirmationBallot { @@ -32,12 +36,16 @@ export interface IConfirmationBallot { export interface IBallotService { hashMultiBallot: (auditableBallot: IAuditableMultiBallot) => string hashBallot512: (auditableBallot: IAuditableSingleBallot) => string + hashPlaintextBallot: (auditableBallot: IAuditablePlaintextBallot) => string decodeAuditableBallot: ( auditableBallot: IAuditableSingleBallot ) => Array | null decodeAuditableMultiBallot: ( auditableBallot: IAuditableMultiBallot ) => Array | null + decodeAuditablePlaintextBallot: ( + auditableBallot: IAuditablePlaintextBallot + ) => Array | null getLayoutProperties: (question: IContest) => IContestLayoutProperties | null getPoints: (question: IContest, answer: IDecodedVoteChoice) => number | null generateSampleAuditableBallot: () => IAuditableSingleBallot | null @@ -53,13 +61,20 @@ export interface IBallotService { content: IAuditableMultiBallot ) => boolean | null isPreferential: (countingAlgorithm?: ICountingAlgorithm) => boolean + verifyPlaintextBallotSignature: ( + ballot_id: string, + election_id: string, + content: IAuditablePlaintextBallot + ) => boolean | null } export const provideBallotService = (): IBallotService => ({ hashMultiBallot, hashBallot512, + hashPlaintextBallot, decodeAuditableBallot, decodeAuditableMultiBallot, + decodeAuditablePlaintextBallot, getLayoutProperties, getPoints, generateSampleAuditableBallot, @@ -67,4 +82,5 @@ export const provideBallotService = (): IBallotService => ({ verifyBallotSignature, verifyMultiBallotSignature, isPreferential, + verifyPlaintextBallotSignature, }) diff --git a/packages/graphql.schema.json b/packages/graphql.schema.json index db5363a23c9..0431eb29949 100644 --- a/packages/graphql.schema.json +++ b/packages/graphql.schema.json @@ -105443,13 +105443,9 @@ "description": null, "args": [], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "uuid", - "ofType": null - } + "kind": "SCALAR", + "name": "uuid", + "ofType": null }, "isDeprecated": false, "deprecationReason": null diff --git a/packages/sequent-core/flake.nix b/packages/sequent-core/flake.nix index c92bc71ec20..56fd3b1945a 100644 --- a/packages/sequent-core/flake.nix +++ b/packages/sequent-core/flake.nix @@ -76,7 +76,7 @@ ]; buildPhase = '' echo 'Build: wasm-pack build' - wasm-pack build --out-name index --release --target web --features=wasmtest + wasm-pack build --out-name index --release --target web --features=wasmtest,default_features ''; installPhase = " # set HOME temporarily to fix npm pack @@ -124,17 +124,17 @@ export CXX=${pkgs.llvmPackages_19.clang-unwrapped}/bin/clang++ export AR=${pkgs.llvmPackages_19.llvm}/bin/llvm-ar export CC_wasm32_unknown_unknown=${pkgs.llvmPackages_19.clang-unwrapped}/bin/clang - - + # Set up the clang resource directory properly CLANG_MAJOR_VERSION="19" CLANG_RESOURCE_DIR="${pkgs.llvmPackages_19.clang-unwrapped}/lib/clang/$CLANG_MAJOR_VERSION" - - + # Use libclang's include directory which has the standard headers LIBCLANG_INCLUDE="${pkgs.llvmPackages_19.libclang.lib}/lib/clang/$CLANG_MAJOR_VERSION/include" - - + export CFLAGS_wasm32_unknown_unknown="-isystem $LIBCLANG_INCLUDE -resource-dir $CLANG_RESOURCE_DIR" export CPPFLAGS="-isystem $LIBCLANG_INCLUDE -resource-dir $CLANG_RESOURCE_DIR" - - + # Debug: Print the paths to verify they exist echo "Clang resource dir: $CLANG_RESOURCE_DIR" echo "Libclang include dir: $LIBCLANG_INCLUDE" diff --git a/packages/sequent-core/src/ballot.rs b/packages/sequent-core/src/ballot.rs index 6cbc83ea714..013d0c2ef4f 100644 --- a/packages/sequent-core/src/ballot.rs +++ b/packages/sequent-core/src/ballot.rs @@ -58,7 +58,7 @@ pub struct ReplicationChoice { Clone, )] pub struct PublicKeyConfig { - pub public_key: String, + pub public_key: Option, pub is_demo: bool, } @@ -1596,6 +1596,9 @@ pub enum ContestEncryptionPolicy { #[strum(serialize = "single-contest")] #[serde(rename = "single-contest")] SINGLE_CONTEST, + #[strum(serialize = "plaintext")] + #[serde(rename = "plaintext")] + PLAINTEXT, } #[allow(non_camel_case_types)] diff --git a/packages/sequent-core/src/ballot_style.rs b/packages/sequent-core/src/ballot_style.rs index 6535d2915fe..64b22f001ed 100644 --- a/packages/sequent-core/src/ballot_style.rs +++ b/packages/sequent-core/src/ballot_style.rs @@ -4,8 +4,9 @@ use crate::ballot::{ self, AreaAnnotations, AreaPresentation, CandidatePresentation, - ContestPresentation, ElectionEventPresentation, ElectionPresentation, - I18nContent, StringifiedPeriodDates, WeightedVotingPolicy, + ContestEncryptionPolicy, ContestPresentation, ElectionEventPresentation, + ElectionPresentation, I18nContent, StringifiedPeriodDates, + WeightedVotingPolicy, }; use crate::serialization::deserialize_with_path::deserialize_value; @@ -109,6 +110,12 @@ pub fn create_ballot_style( .clone() .unwrap_or(WeightedVotingPolicy::default()); + let contest_encryption_policy: ContestEncryptionPolicy = + election_event_presentation + .contest_encryption_policy + .clone() + .unwrap_or(ContestEncryptionPolicy::default()); + let mut area_annotations: Option = None; if event_weighted_voting_policy == WeightedVotingPolicy::AREAS_WEIGHTED_VOTING @@ -126,6 +133,25 @@ pub fn create_ballot_style( .transpose()? .unwrap_or_default(); + // Do not set public key if not using encryption + let public_key = + if ContestEncryptionPolicy::PLAINTEXT == contest_encryption_policy { + ballot::PublicKeyConfig { + public_key: None, + is_demo: false, + } + } else { + public_key + .map(|key| ballot::PublicKeyConfig { + public_key: Some(key), + is_demo: false, + }) + .unwrap_or(ballot::PublicKeyConfig { + public_key: Some(demo_public_key_env.to_string()), + is_demo: true, + }) + }; + Ok(ballot::BallotStyle { id, tenant_id: election.tenant_id, @@ -133,17 +159,7 @@ pub fn create_ballot_style( election_id: election.id, num_allowed_revotes: election.num_allowed_revotes, description: election.description, - public_key: Some( - public_key - .map(|key| ballot::PublicKeyConfig { - public_key: key, - is_demo: false, - }) - .unwrap_or(ballot::PublicKeyConfig { - public_key: demo_public_key_env.to_string(), - is_demo: true, - }), - ), + public_key: Some(public_key), area_id: area.id, area_presentation: Some(area_presentation), contests, diff --git a/packages/sequent-core/src/encrypt.rs b/packages/sequent-core/src/encrypt.rs index 50d6802b28d..29cca2ea52a 100644 --- a/packages/sequent-core/src/encrypt.rs +++ b/packages/sequent-core/src/encrypt.rs @@ -22,6 +22,11 @@ use crate::multi_ballot::{ }; use crate::plaintext::map_decoded_ballot_choices_to_decoded_contests; use crate::plaintext::DecodedVoteContest; +use crate::plaintext_ballot::{ + AuditablePlaintextBallot, AuditablePlaintextBallotContest, + HashablePlaintextBallot, RawHashablePlaintextBallot, + SignedHashablePlaintextBallot, +}; use crate::serialization::base64::Base64Deserialize; use crate::util::date::get_current_date; use crate::util::normalize_vote::normalize_election; @@ -91,9 +96,15 @@ pub fn parse_public_key( .public_key .clone() .ok_or(BallotError::ConsistencyCheck( - "Missing Public Key".to_string(), + "Missing Public Key Config".to_string(), ))?; - Base64Deserialize::deserialize(public_key_config.public_key) + let public_key_str = + public_key_config + .public_key + .ok_or(BallotError::ConsistencyCheck( + "Missing Public Key".to_string(), + ))?; + Base64Deserialize::deserialize(public_key_str) } pub fn recreate_encrypt_cyphertext( @@ -405,6 +416,90 @@ pub fn hash_multi_ballot_sha512( hash::hash_to_array(&bytes) } +//////////////////////////////////////////////////////////////// +/// Plaintext ballots +//////////////////////////////////////////////////////////////// + +pub fn encode_plaintext_ballot>( + ctx: &C, + decoded_contests: &Vec, + config: &BallotStyle, +) -> Result { + if config.contests.len() != decoded_contests.len() { + return Err(BallotError::ConsistencyCheck(format!( + "Invalid number of decoded contests {} != {}", + config.contests.len(), + decoded_contests.len() + ))); + } + + let mut contests: Vec> = vec![]; + + for decoded_contest in decoded_contests { + let contest = config + .contests + .iter() + .find(|contest| contest.id == decoded_contest.contest_id) + .ok_or_else(|| { + BallotError::Serialization(format!( + "Can't find contest with id {} on ballot style", + decoded_contest.contest_id + )) + })?; + let plaintext = contest + .encode_plaintext_contest(&decoded_contest) + .map_err(|err| { + BallotError::Serialization(format!( + "Error encoding plaintext: {}", + err + )) + })?; + + contests.push(AuditablePlaintextBallotContest:: { + contest_id: contest.id.clone(), + plaintext: plaintext, + }); + } + + let mut auditable_ballot = AuditablePlaintextBallot { + version: TYPES_VERSION, + issue_date: get_current_date(), + contests: AuditablePlaintextBallot::serialize_contests::(&contests)?, + ballot_hash: String::from(""), + config: config.clone(), + voter_signing_pk: None, + voter_ballot_signature: None, + }; + + let signed_hashable_ballot = + SignedHashablePlaintextBallot::try_from(&auditable_ballot)?; + let hashable_ballot: HashablePlaintextBallot = + HashablePlaintextBallot::try_from(&signed_hashable_ballot)?; + auditable_ballot.ballot_hash = hash_plaintext_ballot(&hashable_ballot)?; + + Ok(auditable_ballot) +} + +pub fn hash_plaintext_ballot( + hashable_ballot: &HashablePlaintextBallot, +) -> Result { + let sha512_hash = hash_plaintext_ballot_sha512(hashable_ballot) + .map_err(|error| BallotError::Serialization(error.to_string()))?; + let short_hash = shorten_hash(&sha512_hash); + Ok(hex::encode(short_hash)) +} + +pub fn hash_plaintext_ballot_sha512( + hashable_ballot: &HashablePlaintextBallot, +) -> Result { + let raw_hashable_ballot = + RawHashablePlaintextBallot::::try_from(hashable_ballot) + .map_err(|error| StrandError::Generic(format!("{:?}", error)))?; + + let bytes = raw_hashable_ballot.strand_serialize()?; + hash::hash_to_array(&bytes) +} + #[cfg(test)] mod tests { use crate::ballot_codec::bigint; diff --git a/packages/sequent-core/src/fixtures/ballot_codec.rs b/packages/sequent-core/src/fixtures/ballot_codec.rs index e458bb16287..59f5657ebb5 100644 --- a/packages/sequent-core/src/fixtures/ballot_codec.rs +++ b/packages/sequent-core/src/fixtures/ballot_codec.rs @@ -361,7 +361,9 @@ pub fn get_writein_ballot_style() -> BallotStyle { num_allowed_revotes: Some(1), description: Some("Write-ins simple".into()), public_key: Some(PublicKeyConfig { - public_key: "ajR/I9RqyOwbpsVRucSNOgXVLCvLpfQxCgPoXGQ2RF4".into(), + public_key: Some( + "ajR/I9RqyOwbpsVRucSNOgXVLCvLpfQxCgPoXGQ2RF4".into(), + ), is_demo: false, }), area_id: "9570d82a-d92a-44d7-b483-d5a6c8c398a8".into(), diff --git a/packages/sequent-core/src/lib.rs b/packages/sequent-core/src/lib.rs index 71fd02b022c..f663c5c4d99 100644 --- a/packages/sequent-core/src/lib.rs +++ b/packages/sequent-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod ballot_style; pub mod error; #[cfg(feature = "default_features")] pub mod multi_ballot; +pub mod plaintext_ballot; pub mod types; //pub use ballot::*; #[cfg(feature = "default_features")] diff --git a/packages/sequent-core/src/plaintext.rs b/packages/sequent-core/src/plaintext.rs index 4fa3c6576e2..596f5f1e0ac 100644 --- a/packages/sequent-core/src/plaintext.rs +++ b/packages/sequent-core/src/plaintext.rs @@ -7,8 +7,12 @@ use crate::ballot_codec::multi_ballot::{ }; use crate::ballot_codec::PlaintextCodec; use crate::multi_ballot::AuditableMultiBallotContests; +use crate::plaintext_ballot::AuditablePlaintextBallotContest; use crate::types::ceremonies::CountingAlgType; -use crate::{ballot::*, multi_ballot::AuditableMultiBallot}; +use crate::{ + ballot::*, multi_ballot::AuditableMultiBallot, + plaintext_ballot::AuditablePlaintextBallot, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -145,6 +149,44 @@ pub fn map_to_decoded_contest>( Ok(decoded_contests) } +pub fn map_to_decoded_plaintext_contest>( + ballot: &AuditablePlaintextBallot, +) -> Result, String> { + let mut decoded_contests = vec![]; + if ballot.config.contests.len() != ballot.contests.len() { + return Err(format!( + "Invalid number of contests {} != {}", + ballot.config.contests.len(), + ballot.contests.len() + )); + } + + let ballot_contests: Vec> = + ballot.deserialize_contests().map_err(|err| { + format!( + "Error deserializing auditable plaintext ballot contest {:?}", + err + ) + })?; + for contest in &ballot_contests { + let found_contest = ballot + .config + .contests + .iter() + .find(|contest_el| contest_el.id == contest.contest_id) + .ok_or_else(|| { + format!( + "Can't find contest with id {} on ballot style", + contest.contest_id + ) + })?; + let decoded_plaintext = + found_contest.decode_plaintext_contest(&contest.plaintext)?; + decoded_contests.push(decoded_plaintext); + } + Ok(decoded_contests) +} + pub fn map_decoded_ballot_choices_to_decoded_contests( decoded_ballot_choices: DecodedBallotChoices, contests: &Vec, diff --git a/packages/sequent-core/src/plaintext_ballot.rs b/packages/sequent-core/src/plaintext_ballot.rs new file mode 100644 index 00000000000..a2f01933bc8 --- /dev/null +++ b/packages/sequent-core/src/plaintext_ballot.rs @@ -0,0 +1,393 @@ +// SPDX-FileCopyrightText: 2024 Sequent Legal +// +// SPDX-License-Identifier: AGPL-3.0-only +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; +use strand::signature::{ + StrandSignature, StrandSignaturePk, StrandSignatureSk, +}; + +use crate::encrypt::hash_ballot_style; +use crate::error::BallotError; +use crate::serialization::base64::{Base64Deserialize, Base64Serialize}; +use strand::{backend::ristretto::RistrettoCtx, context::Ctx}; + +use crate::ballot::TYPES_VERSION; +use crate::ballot::{BallotStyle, SignedContent}; + +use crate::ballot::get_ballot_bytes_for_signing; +use strand::serialization::StrandSerialize; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct AuditablePlaintextBallot { + pub version: u32, + pub issue_date: String, + pub config: BallotStyle, + // String serialization of AuditablePlaintextBallotContests through + // + // self::serialize_contests can be deserialized with + // self::deserialize_contests + pub contests: Vec, + pub ballot_hash: String, + pub voter_signing_pk: Option, + pub voter_ballot_signature: Option, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub struct AuditablePlaintextBallotContest { + pub contest_id: String, + pub plaintext: C::P, +} + +#[derive( + BorshSerialize, Serialize, Deserialize, PartialEq, Eq, Debug, Clone, +)] +pub struct HashablePlaintextBallot { + pub version: u32, + pub issue_date: String, + // String serialization of HashablePlaintextBallotContests through + // + // self::serialize_contests can be deserialized with + // self::deserialize_contests + pub contests: Vec, + pub config: String, + pub ballot_style_hash: String, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct SignedHashablePlaintextBallot { + pub version: u32, + pub issue_date: String, + // String serialization of HashablePlaintextBallotContests through + // + // self::serialize_contests can be deserialized with + // self::deserialize_contests + pub contests: Vec, + pub config: String, + pub ballot_style_hash: String, + pub voter_signing_pk: Option, + pub voter_ballot_signature: Option, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub struct HashablePlaintextBallotContest { + pub contest_id: String, + pub plaintext: C::P, +} + +#[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +pub struct RawHashablePlaintextBallot { + pub version: u32, + pub issue_date: String, + pub contests: Vec>, +} + +impl AuditablePlaintextBallot { + pub fn deserialize_contests( + &self, + ) -> Result>, BallotError> { + self.contests + .clone() + .into_iter() + .map(|auditable_ballot_contest_serialized| { + Base64Deserialize::deserialize( + auditable_ballot_contest_serialized.clone(), + ) + .map_err(|err| BallotError::Serialization(err.to_string())) + }) + .collect() + } + + pub fn serialize_contests( + contests: &Vec>, + ) -> Result, BallotError> { + contests + .clone() + .into_iter() + .map(|auditable_ballot_contest| { + Base64Serialize::serialize(&auditable_ballot_contest) + }) + .collect::>>() + .into_iter() + .collect() + } +} + +impl HashablePlaintextBallot { + pub fn deserialize_contests( + &self, + ) -> Result>, BallotError> { + self.contests + .clone() + .into_iter() + .map(|hashable_ballot_contest_serialized| { + Base64Deserialize::deserialize( + hashable_ballot_contest_serialized.clone(), + ) + .map_err(|err| BallotError::Serialization(err.to_string())) + }) + .collect() + } + + pub fn serialize_contests( + contests: &Vec>, + ) -> Result, BallotError> { + contests + .clone() + .into_iter() + .map(|hashable_ballot_contest| { + Base64Serialize::serialize(&hashable_ballot_contest) + }) + .collect::>>() + .into_iter() + .collect() + } +} + +impl SignedHashablePlaintextBallot { + pub fn deserialize_contests( + &self, + ) -> Result>, BallotError> { + let hashable_ballot = HashablePlaintextBallot::try_from(self)?; + + hashable_ballot.deserialize_contests() + } + + pub fn serialize_contests( + contests: &Vec>, + ) -> Result, BallotError> { + HashablePlaintextBallot::serialize_contests(contests) + } +} + +impl TryFrom<&AuditablePlaintextBallot> for HashablePlaintextBallot { + type Error = BallotError; + + fn try_from(value: &AuditablePlaintextBallot) -> Result { + if TYPES_VERSION != value.version { + return Err(BallotError::Serialization(format!( + "Unexpected version {}, expected {}", + value.version.to_string(), + TYPES_VERSION + ))); + } + + let contests = value.deserialize_contests::()?; + let hashable_ballot_contests = contests + .iter() + .map(|auditable_ballot_contest| { + let hashable_ballot_contest = + HashablePlaintextBallotContest::::from( + auditable_ballot_contest, + ); + hashable_ballot_contest + }) + .collect(); + + let ballot_style_hash = + hash_ballot_style(&value.config).map_err(|error| { + BallotError::Serialization(format!( + "Failed to hash ballot style: {}", + error + )) + })?; + + Ok(HashablePlaintextBallot { + version: TYPES_VERSION, + issue_date: value.issue_date.clone(), + contests: HashablePlaintextBallot::serialize_contests::< + RistrettoCtx, + >(&hashable_ballot_contests)?, + config: value.config.id.clone(), + ballot_style_hash: ballot_style_hash, + }) + } +} + +impl TryFrom<&HashablePlaintextBallot> + for RawHashablePlaintextBallot +{ + type Error = BallotError; + + fn try_from(value: &HashablePlaintextBallot) -> Result { + let contests = value.deserialize_contests::()?; + Ok(RawHashablePlaintextBallot { + version: value.version, + issue_date: value.issue_date.clone(), + contests: contests, + }) + } +} + +impl From<&AuditablePlaintextBallotContest> + for HashablePlaintextBallotContest +{ + fn from( + value: &AuditablePlaintextBallotContest, + ) -> HashablePlaintextBallotContest { + HashablePlaintextBallotContest { + contest_id: value.contest_id.clone(), + plaintext: value.plaintext.clone(), + } + } +} + +impl TryFrom<&AuditablePlaintextBallot> for SignedHashablePlaintextBallot { + type Error = BallotError; + + fn try_from(value: &AuditablePlaintextBallot) -> Result { + if TYPES_VERSION != value.version { + return Err(BallotError::Serialization(format!( + "Unexpected version {}, expected {}", + value.version.to_string(), + TYPES_VERSION + ))); + } + + let contests = value.deserialize_contests::()?; + let hashable_ballot_contest: Vec< + HashablePlaintextBallotContest, + > = contests + .iter() + .map(|auditable_ballot_contest| { + let hashable_ballot_contest = + HashablePlaintextBallotContest::::from( + auditable_ballot_contest, + ); + hashable_ballot_contest + }) + .collect(); + let ballot_style_hash = + hash_ballot_style(&value.config).map_err(|error| { + BallotError::Serialization(format!( + "Failed to hash ballot style: {}", + error + )) + })?; + Ok(SignedHashablePlaintextBallot { + version: TYPES_VERSION, + issue_date: value.issue_date.clone(), + contests: HashablePlaintextBallot::serialize_contests::< + RistrettoCtx, + >(&hashable_ballot_contest)?, + config: value.config.id.clone(), + ballot_style_hash: ballot_style_hash, + voter_signing_pk: value.voter_signing_pk.clone(), + voter_ballot_signature: value.voter_ballot_signature.clone(), + }) + } +} + +impl TryFrom<&SignedHashablePlaintextBallot> for HashablePlaintextBallot { + type Error = BallotError; + fn try_from( + value: &SignedHashablePlaintextBallot, + ) -> Result { + if TYPES_VERSION != value.version { + return Err(BallotError::Serialization(format!( + "Unexpected version {}, expected {}", + value.version.to_string(), + TYPES_VERSION + ))); + } + + Ok(HashablePlaintextBallot { + version: TYPES_VERSION, + issue_date: value.issue_date.clone(), + contests: value.contests.clone(), + config: value.config.clone(), + ballot_style_hash: value.ballot_style_hash.clone(), + }) + } +} + +pub fn sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key( + ballot_id: &str, + election_id: &str, + hashable_plaintext_ballot: &HashablePlaintextBallot, +) -> Result { + // Get ballot_bytes_for_signing + let content_bytes = hashable_plaintext_ballot + .strand_serialize() + .map_err(|err| format!("Error getting signature bytes: {err}"))?; + let ballot_bytes = + get_ballot_bytes_for_signing(ballot_id, election_id, &content_bytes); + + // Generate voter ephemeral key for signing + let secret_key = StrandSignatureSk::gen() + .map_err(|err| format!("Error generating secret key: {err}"))?; + let public_key = StrandSignaturePk::from_sk(&secret_key) + .map_err(|err| format!("Error generating public key: {err}"))?; + + let ballot_signature = secret_key + .sign(&ballot_bytes) + .map_err(|err| format!("Failed to sign the ballot: {err}"))?; + + let public_key = public_key + .to_der_b64_string() + .map_err(|err| format!("Failed to serialize the public key: {err}"))?; + + let signature = ballot_signature + .to_b64_string() + .map_err(|err| format!("Failed to serialize signature: {err}"))?; + + Ok(SignedContent { + public_key, + signature, + }) +} + +pub fn verify_plaintext_ballot_signature( + ballot_id: &str, + election_id: &str, + signed_hashable_plaintext_ballot: &SignedHashablePlaintextBallot, +) -> Result, String> { + let (signature, public_key) = + if let (Some(voter_ballot_signature), Some(voter_signing_pk)) = ( + signed_hashable_plaintext_ballot + .voter_ballot_signature + .clone(), + signed_hashable_plaintext_ballot.voter_signing_pk.clone(), + ) { + (voter_ballot_signature, voter_signing_pk) + } else { + return Ok(None); + }; + + let voter_signing_pk = StrandSignaturePk::from_der_b64_string(&public_key) + .map_err(|err| { + format!( + "Failed to deserialize signature from hashable plaintext ballot: {}", + err + ) + })?; + + let hashable_plaintext_ballot: HashablePlaintextBallot = + signed_hashable_plaintext_ballot.try_into().map_err(|err| { + format!("Failed to convert to hashable plaintext ballot: {}", err) + })?; + + let content = hashable_plaintext_ballot.strand_serialize().map_err(|err| { + format!( + "Failed to deserialize signature from hashable plaintext ballot: {}", + err + ) + })?; + + let ballot_bytes = + get_ballot_bytes_for_signing(ballot_id, election_id, &content); + + let ballot_signature = StrandSignature::from_b64_string(&signature) + .map_err(|err| { + format!( + "Failed to deserialize signature from hashable multi ballot: {}", + err + ) + })?; + + voter_signing_pk + .verify(&ballot_signature, &ballot_bytes) + .map_err(|err| format!("Failed to verify signature: {err}"))?; + + Ok(Some((voter_signing_pk, ballot_signature))) +} diff --git a/packages/sequent-core/src/types/hasura/core.rs b/packages/sequent-core/src/types/hasura/core.rs index 586f85a409c..af044d37634 100644 --- a/packages/sequent-core/src/types/hasura/core.rs +++ b/packages/sequent-core/src/types/hasura/core.rs @@ -389,7 +389,7 @@ pub struct TallySession { pub election_ids: Option>, pub area_ids: Option>, pub is_execution_completed: bool, - pub keys_ceremony_id: String, + pub keys_ceremony_id: Option, pub execution_status: Option, pub threshold: i64, pub configuration: Option, diff --git a/packages/sequent-core/src/wasm/wasm.rs b/packages/sequent-core/src/wasm/wasm.rs index b7f902f8191..be97af73747 100644 --- a/packages/sequent-core/src/wasm/wasm.rs +++ b/packages/sequent-core/src/wasm/wasm.rs @@ -17,6 +17,7 @@ use crate::interpret_plaintext::{ }; use crate::multi_ballot::*; use crate::plaintext::*; +use crate::plaintext_ballot::*; use crate::serialization::deserialize_with_path::deserialize_value; use crate::services::generate_urls::get_auth_url; use crate::services::generate_urls::AuthAction; @@ -234,6 +235,78 @@ pub fn to_hashable_multi_ballot_js( }) } +#[allow(clippy::all)] +#[wasm_bindgen] +pub fn to_hashable_plaintext_ballot_js( + auditable_plaintext_ballot_json: JsValue, +) -> Result { + // Parse input + let auditable_plaintext_ballot_js: Value = + serde_wasm_bindgen::from_value(auditable_plaintext_ballot_json) + .map_err(|err| { + format!("Failed to parse auditable plaintext ballot: {}", err) + }) + .into_json()?; + let auditable_plaintext_ballot: AuditablePlaintextBallot = + deserialize_value(auditable_plaintext_ballot_js) + .map_err(|err| { + format!("Failed to parse auditable plaintext ballot: {}", err) + }) + .into_json()?; + + // Test deserializing auditable ballot contests + let _auditable_ballot_contests = auditable_plaintext_ballot + .deserialize_contests::() + .map_err(|err| { + JsValue::from(ErrorStatus { + error_type: BallotError::DESERIALIZE_AUDITABLE_ERROR, + error_msg: format!( + "Failed to deserialize auditable plaintext ballot contests: {}", + err + ), + }) + })?; + + // Convert auditable ballot to signed hashable ballot + let deserialized_plaintext_ballot: SignedHashablePlaintextBallot = + SignedHashablePlaintextBallot::try_from(&auditable_plaintext_ballot).map_err(|err| { + JsValue::from(ErrorStatus { + error_type: BallotError::CONVERT_ERROR, + error_msg: format!( + "Failed to convert auditable plaintext ballot to hashable plaintext ballot: {}", + err + ), + }) + })?; + + // Test deserializing hashable ballot contests + let _hashable_ballot_contests = deserialized_plaintext_ballot + .deserialize_contests::() + .map_err(|err| { + JsValue::from(ErrorStatus { + error_type: BallotError::DESERIALIZE_HASHABLE_ERROR, + error_msg: format!( + "Failed to deserialize hashable plaintext ballot contests: {}", + err + ), + }) + })?; + + // Serialize the hashable ballot + let serializer = Serializer::json_compatible(); + deserialized_plaintext_ballot + .serialize(&serializer) + .map_err(|err| { + JsValue::from(ErrorStatus { + error_type: BallotError::SERIALIZE_ERROR, + error_msg: format!( + "Failed to serialize hashable plaintext ballot: {}", + err + ), + }) + }) +} + #[allow(clippy::all)] #[wasm_bindgen] pub fn hash_auditable_ballot_js( @@ -309,6 +382,41 @@ pub fn hash_auditable_multi_ballot_js( .into_json() } +#[allow(clippy::all)] +#[wasm_bindgen] +pub fn hash_auditable_plaintext_ballot_js( + auditable_plaintext_ballot_json: JsValue, +) -> Result { + // parse input + let auditable_plaintext_ballot_js: serde_json::Value = + serde_wasm_bindgen::from_value(auditable_plaintext_ballot_json) + .map_err(|err| { + format!("Error deserializing auditable multi ballot into value: {err}",) + }) + .into_json()?; + let auditable_plaintext_ballot: AuditablePlaintextBallot = + deserialize_value(auditable_plaintext_ballot_js) + .map_err(|err| { + format!("Error deserializing auditable multi ballot: {err}",) + }) + .into_json()?; + + let hashable_plaintext_ballot = + HashablePlaintextBallot::try_from(&auditable_plaintext_ballot).map_err(|err| { + format!( + "Error converting auditable ballot into hashable plaintext ballot: {err}", + ) + })?; + + // return hash + let hash_string: String = hash_plaintext_ballot(&hashable_plaintext_ballot) + .map_err(|err| format!("Error hashing plaintext ballot: {err}",)) + .into_json()?; + serde_wasm_bindgen::to_value(&hash_string) + .map_err(|err| format!("Error writing javascript string: {err}",)) + .into_json() +} + #[allow(clippy::all)] #[wasm_bindgen] pub fn encrypt_decoded_contest_js( @@ -395,6 +503,49 @@ pub fn encrypt_decoded_multi_contest_js( .into_json() } +#[allow(clippy::all)] +#[wasm_bindgen] +pub fn encode_plaintext_contest_js( + decoded_contests_json: JsValue, + election_json: JsValue, +) -> Result { + // parse inputs + let decoded_contests_js: Value = + serde_wasm_bindgen::from_value(decoded_contests_json) + .map_err(|err| format!("Error parsing decoded contests: {}", err)) + .into_json()?; + let decoded_contests: Vec = + deserialize_value(decoded_contests_js) + .map_err(|err| format!("Error parsing decoded contests: {}", err)) + .into_json()?; + let election_js: Value = serde_wasm_bindgen::from_value(election_json) + .map_err(|err| format!("Error parsing election: {}", err)) + .into_json()?; + let election: BallotStyle = deserialize_value(election_js) + .map_err(|err| format!("Error parsing election: {}", err)) + .into_json()?; + // create context + let ctx = RistrettoCtx; + + // encode ballot + let auditable_ballot = encode_plaintext_ballot::( + &ctx, + &decoded_contests, + &election, + ) + .map_err(|err| format!("Error encrypting decoded contests {:?}", err)) + .into_json()?; + + // convert to json output + let serializer = Serializer::json_compatible(); + auditable_ballot + .serialize(&serializer) + .map_err(|err| { + format!("Error converting auditable ballot to json {:?}", err) + }) + .into_json() +} + // before: map_to_decoded_ballot #[allow(clippy::all)] #[wasm_bindgen] @@ -431,6 +582,41 @@ pub fn decode_auditable_ballot_js( .into_json() } +#[allow(clippy::all)] +#[wasm_bindgen] +pub fn decode_auditable_plaintext_ballot_js( + auditable_ballot_json: JsValue, +) -> Result { + let auditable_ballot_js: Value = + serde_wasm_bindgen::from_value(auditable_ballot_json) + .map_err(|err| { + format!( + "Error parsing auditable plaintext ballot javascript string: {}", + err + ) + }) + .into_json()?; + let auditable_ballot: AuditablePlaintextBallot = + deserialize_value(auditable_ballot_js) + .map_err(|err| { + format!( + "Error parsing auditable plaintext ballot javascript string: {}", + err + ) + }) + .into_json()?; + let plaintext = + map_to_decoded_plaintext_contest::(&auditable_ballot) + .into_json()?; + let serializer = Serializer::json_compatible(); + plaintext + .serialize(&serializer) + .map_err(|err| { + format!("Error converting decoded ballot to json {:?}", err) + }) + .into_json() +} + // before: map_to_decoded_ballot #[allow(clippy::all)] #[wasm_bindgen] @@ -1106,6 +1292,57 @@ pub fn sign_hashable_multi_ballot_with_ephemeral_voter_signing_key_js( .into_json() } +#[wasm_bindgen] +pub fn sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js( + ballot_id: JsValue, + election_id: JsValue, + auditable_plaintext_ballot_json: JsValue, +) -> Result { + // Deserialize inputs + let ballot_id: String = serde_wasm_bindgen::from_value(ballot_id) + .map_err(|err| format!("Error deserializing ballot_id: {err}")) + .into_json()?; + let election_id: String = serde_wasm_bindgen::from_value(election_id) + .map_err(|err| format!("Error deserializing election_id: {err}")) + .into_json()?; + let auditable_plaintext_ballot_js: Value = + serde_wasm_bindgen::from_value(auditable_plaintext_ballot_json) + .map_err(|err| { + format!( + "Error parsing auditable ballot javascript string: {}", + err + ) + }) + .into_json()?; + let auditable_plaintext_ballot: AuditablePlaintextBallot = + deserialize_value(auditable_plaintext_ballot_js) + .map_err(|err| { + format!("Error deserializing auditable plaintext ballot: {err}",) + }) + .into_json()?; + + let hashable_plaintext_ballot = + HashablePlaintextBallot::try_from(&auditable_plaintext_ballot).map_err(|err| { + format!( + "Error converting auditable ballot into hashable plaintext ballot: {err}", + ) + }) + .into_json()?; + + // Generates ephemeral voter signing key and signs the ballot + let signed_content = + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key( + &ballot_id, + &election_id, + &hashable_plaintext_ballot, + ) + .map_err(|err| format!("Error signing the ballot: {err}")) + .into_json()?; + serde_wasm_bindgen::to_value(&signed_content) + .map_err(|err| format!("Error writing javascript string: {err}",)) + .into_json() +} + // returns true/false if verified/no-signature, error if the signature can't be // verified #[wasm_bindgen] @@ -1203,3 +1440,54 @@ pub fn verify_multi_ballot_signature_js( .map_err(|err| format!("Error writing javascript string: {err}",)) .into_json() } + +#[wasm_bindgen] +pub fn verify_plaintext_ballot_signature_js( + ballot_id: JsValue, + election_id: JsValue, + auditable_plaintext_ballot_json: JsValue, +) -> Result { + // Deserialize inputs + let ballot_id: String = serde_wasm_bindgen::from_value(ballot_id) + .map_err(|err| format!("Error deserializing ballot_id: {err}")) + .into_json()?; + let election_id: String = serde_wasm_bindgen::from_value(election_id) + .map_err(|err| format!("Error deserializing election_id: {err}")) + .into_json()?; + let auditable_plaintext_ballot_js: Value = + serde_wasm_bindgen::from_value(auditable_plaintext_ballot_json) + .map_err(|err| { + format!( + "Error parsing auditable ballot javascript string: {}", + err + ) + }) + .into_json()?; + let auditable_plaintext_ballot: AuditablePlaintextBallot = + deserialize_value(auditable_plaintext_ballot_js) + .map_err(|err| { + format!("Error deserializing auditable plaintext ballot: {err}",) + }) + .into_json()?; + + let signed_hashable_plaintext_ballot = + SignedHashablePlaintextBallot::try_from(&auditable_plaintext_ballot).map_err(|err| { + format!( + "Error converting auditable ballot into hashable plaintext ballot: {err}", + ) + }) + .into_json()?; + + // Verifies the ballot signature + let result = verify_plaintext_ballot_signature( + &ballot_id, + &election_id, + &signed_hashable_plaintext_ballot, + ) + .map_err(|err| format!("Error verifying the ballot signature: {err}")) + .into_json()?; + + serde_wasm_bindgen::to_value(&result.is_some()) + .map_err(|err| format!("Error writing javascript string: {err}",)) + .into_json() +} diff --git a/packages/sequent-core/tests/wasm/wasm.rs b/packages/sequent-core/tests/wasm/wasm.rs index 48f49789cd2..1f34c949ef9 100644 --- a/packages/sequent-core/tests/wasm/wasm.rs +++ b/packages/sequent-core/tests/wasm/wasm.rs @@ -7,26 +7,72 @@ use wasm_bindgen::JsValue; use wasm_bindgen_test::*; use web_sys::js_sys::JSON; -use sequent_core::wasm::wasm::verify_ballot_signature_js; +use sequent_core::wasm::wasm::{ + decode_auditable_plaintext_ballot_js, encode_plaintext_contest_js, + hash_auditable_plaintext_ballot_js, + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js, + to_hashable_plaintext_ballot_js, verify_plaintext_ballot_signature_js, +}; // Configure tests to run in a browser environment wasm_bindgen_test_configure!(run_in_browser); -// Store the large valid JSON as a constant raw string -const VALID_BALLOT_JSON: &str = r#"{"version":1,"issue_date":"13/10/2025","config":{"id":"e18630c5-ed89-495f-8a1d-f488084c64f1","tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5","election_event_id":"a00cbd54-d7c4-4440-a614-261d5d8d573b","election_id":"4104d326-9e7d-48d7-b047-b6908a11c90f","num_allowed_revotes":null,"description":null,"public_key":{"public_key":"zI/lPoirqhY8EzaAZuOGO5vwmxXxqRcGn3ubK+Z0GGw","is_demo":false},"area_id":"c260a35d-0cb5-4988-ae73-2e34cc734bbe","area_presentation":{"allow_early_voting":"no_early_voting"},"contests":[{"id":"108e054a-60a3-4bb4-b72b-50fe8163a958","tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5","election_event_id":"a00cbd54-d7c4-4440-a614-261d5d8d573b","election_id":"4104d326-9e7d-48d7-b047-b6908a11c90f","name":"ee1e1q1","name_i18n":{"cat":"ee1e1q1","eu":"ee1e1q1","fr":"ee1e1q1","gl":"ee1e1q1","en":"ee1e1q1","nl":"ee1e1q1","tl":"ee1e1q1","es":"ee1e1q1"},"description":null,"description_i18n":{},"alias":null,"alias_i18n":{},"max_votes":1,"min_votes":0,"winning_candidates_num":1,"voting_type":"non-preferential","counting_algorithm":"plurality-at-large","is_encrypted":true,"candidates":[{"id":"7a9a87c8-8dbb-45c4-aeac-ee21597f06f4","tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5","election_event_id":"a00cbd54-d7c4-4440-a614-261d5d8d573b","election_id":"4104d326-9e7d-48d7-b047-b6908a11c90f","contest_id":"108e054a-60a3-4bb4-b72b-50fe8163a958","name":"ee1e1q1a2","name_i18n":{"nl":"ee1e1q1a2","es":"ee1e1q1a2","gl":"ee1e1q1a2","cat":"ee1e1q1a2","fr":"ee1e1q1a2","en":"ee1e1q1a2","eu":"ee1e1q1a2","tl":"ee1e1q1a2"},"description":null,"description_i18n":{},"alias":null,"alias_i18n":{},"candidate_type":null,"presentation":{"i18n":{"cat":{"name":"ee1e1q1a2"},"gl":{"name":"ee1e1q1a2"},"eu":{"name":"ee1e1q1a2"},"tl":{"name":"ee1e1q1a2"},"nl":{"name":"ee1e1q1a2"},"fr":{"name":"ee1e1q1a2"},"es":{"name":"ee1e1q1a2"},"en":{"name":"ee1e1q1a2"}},"is_explicit_invalid":null,"is_explicit_blank":null,"is_disabled":null,"is_category_list":null,"invalid_vote_position":null,"is_write_in":null,"sort_order":null,"urls":null,"subtype":null},"annotations":null},{"id":"c77e57b8-93df-4dce-a8e3-4b2126a590c5","tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5","election_event_id":"a00cbd54-d7c4-4440-a614-261d5d8d573b","election_id":"4104d326-9e7d-48d7-b047-b6908a11c90f","contest_id":"108e054a-60a3-4bb4-b72b-50fe8163a958","name":"ee1e1q1a1","name_i18n":{"fr":"ee1e1q1a1","nl":"ee1e1q1a1","gl":"ee1e1q1a1","tl":"ee1e1q1a1","en":"ee1e1q1a1","es":"ee1e1q1a1","cat":"ee1e1q1a1","eu":"ee1e1q1a1"},"description":null,"description_i18n":{},"alias":null,"alias_i18n":{},"candidate_type":null,"presentation":{"i18n":{"cat":{"name":"ee1e1q1a1"},"en":{"name":"ee1e1q1a1"},"eu":{"name":"ee1e1q1a1"},"tl":{"name":"ee1e1q1a1"},"nl":{"name":"ee1e1q1a1"},"fr":{"name":"ee1e1q1a1"},"es":{"name":"ee1e1q1a1"},"gl":{"name":"ee1e1q1a1"}},"is_explicit_invalid":null,"is_explicit_blank":null,"is_disabled":null,"is_category_list":null,"invalid_vote_position":null,"is_write_in":null,"sort_order":null,"urls":null,"subtype":null},"annotations":null}],"presentation":{"i18n":{"cat":{"name":"ee1e1q1"},"tl":{"name":"ee1e1q1"},"nl":{"name":"ee1e1q1"},"en":{"name":"ee1e1q1"},"eu":{"name":"ee1e1q1"},"gl":{"name":"ee1e1q1"},"fr":{"name":"ee1e1q1"},"es":{"name":"ee1e1q1"}},"allow_writeins":null,"base32_writeins":null,"invalid_vote_policy":null,"under_vote_policy":null,"blank_vote_policy":null,"over_vote_policy":null,"pagination_policy":null,"cumulative_number_of_checkboxes":null,"shuffle_categories":null,"shuffle_category_list":null,"show_points":null,"enable_checkable_lists":null,"candidates_order":"alphabetical","candidates_selection_policy":null,"candidates_icon_checkbox_policy":null,"max_selections_per_type":null,"types_presentation":null,"sort_order":null,"columns":null},"created_at":"2025-10-13T10:27:58.987774+00:00","annotations":null}],"election_event_presentation":{"i18n":{"nl":{"name":"ee1"},"eu":{"name":"ee1"},"fr":{"name":"ee1"},"en":{"name":"ee1"},"cat":{"name":"ee1"},"tl":{"name":"ee1"},"gl":{"name":"ee1"},"es":{"name":"ee1"}},"materials":{"activated":false},"language_conf":{"enabled_language_codes":["en"],"default_language_code":"en"},"logo_url":null,"redirect_finish_url":null,"css":null,"skip_election_list":false,"show_user_profile":false,"show_cast_vote_logs":"hide-logs-tab","elections_order":"alphabetical","voting_portal_countdown_policy":{"policy":"NO_COUNTDOWN","countdown_anticipation_secs":60,"countdown_alert_anticipation_secs":180},"custom_urls":{"login":null,"enrollment":null,"saml":null},"keys_ceremony_policy":null,"contest_encryption_policy":"single-contest","decoded_ballot_inclusion_policy":"not-included","locked_down":"not-locked-down","publish_policy":null,"enrollment":null,"otp":null,"voter_signing_policy":"no-signature","weighted_voting_policy":"disabled-weighted-voting"},"election_presentation":{"i18n":{"cat":{"name":"ee1e1"},"eu":{"name":"ee1e1"},"nl":{"name":"ee1e1"},"tl":{"name":"ee1e1"},"es":{"name":"ee1e1"},"en":{"name":"ee1e1"},"fr":{"name":"ee1e1"},"gl":{"name":"ee1e1"}},"dates":null,"language_conf":{"enabled_language_codes":["en"],"default_language_code":"en"},"contests_order":null,"audit_button_cfg":null,"sort_order":null,"cast_vote_confirm":null,"cast_vote_gold_level":null,"start_screen_title_policy":null,"is_grace_priod":null,"grace_period_policy":null,"grace_period_secs":null,"init_report":null,"manual_start_voting_period":null,"voting_period_end":null,"tally":null,"initialization_report_policy":null,"security_confirmation_policy":null},"election_dates":{"first_started_at":null,"last_started_at":null,"first_paused_at":null,"last_paused_at":null,"first_stopped_at":null,"last_stopped_at":null,"scheduled_event_dates":{}},"election_event_annotations":{},"election_annotations":{},"area_annotations":null},"contests":["JAAAADEwOGUwNTRhLTYwYTMtNGJiNC1iNzJiLTUwZmU4MTYzYTk1OPLkqkr18Pbn6SRi6n4DT4Lqgh146iw4VaZ5RZCIHCJZSHAt4m40ySEtHJQq/nsD/dacGyQXzOfLPK834dUk0S4BBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADomKxAJLuXK5gWi9S/dL+jyfy+tHNEsWysN5iJr02kDlgJmTX9FOSgYQUkEXdXcbTuDHD4Y7tt+bM8mo3K99oBSKR+1R2cNIgsbpnojCw49NZLO6WCO7lPmpuGxsvSUAakDvDV7g05LeTmAGk7k1d8dD8Dt75L09POYxHOiCI3DQ"],"ballot_hash":"07ad7361eaa62d708a1df1785f0fc3366fc6eff71e16a416fc3de42a298dbc34","voter_signing_pk":"MCowBQYDK2VwAyEAmbV8qgSr7vizQPHQF8ORkGNbgzI+C0lsnT5HMz48I64=","voter_ballot_signature":"fqr3onxfU8E2EfwdyVsGzHJ20eWykh2PyjSj4T01wIVtz85D0miHcNg0Fjg5aRAFrtLwHCsOwtcHPVb3yugcBQ=="}"#; - #[wasm_bindgen_test] fn test_verify_success() { - let ballot_id = JsValue::from_str( - "07ad7361eaa62d708a1df1785f0fc3366fc6eff71e16a416fc3de42a298dbc34", + // Encode the plaintext ballot using existing test data + let decoded_contests_json = + JSON::parse(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let encode_result = + encode_plaintext_contest_js(decoded_contests_json, election_json); + assert!(encode_result.is_ok(), "Encoding should succeed"); + let auditable_ballot = encode_result.unwrap(); + + // Get the ballot hash for use as ballot_id + let hash_result = + hash_auditable_plaintext_ballot_js(auditable_ballot.clone()); + assert!(hash_result.is_ok(), "Hashing should succeed"); + let ballot_hash: String = + serde_wasm_bindgen::from_value(hash_result.unwrap()).unwrap(); + + let ballot_id = JsValue::from_str(&ballot_hash); + let election_id = JsValue::from_str("f2f1065e-b784-46d1-b81a-c71bfeb9ad55"); + + // Sign the ballot + let sign_result = + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js( + ballot_id.clone(), + election_id.clone(), + auditable_ballot.clone(), + ); + assert!( + sign_result.is_ok(), + "Signing should succeed. Error: {:?}", + sign_result.err() ); - let election_id = JsValue::from_str("4104d326-9e7d-48d7-b047-b6908a11c90f"); - let auditable_multi_ballot_json = JSON::parse(VALID_BALLOT_JSON).unwrap(); - let result = verify_ballot_signature_js( + // Get the signed content + let signed_content: Value = + serde_wasm_bindgen::from_value(sign_result.unwrap()).unwrap(); + + // Update the auditable ballot with signature information + let mut auditable_ballot_value: Value = + serde_wasm_bindgen::from_value(auditable_ballot).unwrap(); + auditable_ballot_value["voter_signing_pk"] = + signed_content["public_key"].clone(); + auditable_ballot_value["voter_ballot_signature"] = + signed_content["signature"].clone(); + + // Convert back to JsValue + let signed_auditable_ballot = + JSON::parse(&auditable_ballot_value.to_string()).unwrap(); + + // Verify the signature + let result = verify_plaintext_ballot_signature_js( ballot_id, election_id, - auditable_multi_ballot_json, + signed_auditable_ballot, ); assert!( @@ -45,23 +91,55 @@ fn test_verify_success() { #[wasm_bindgen_test] fn test_verify_fails_on_bad_signature() { - let ballot_id = JsValue::from_str( - "e1c33f34f847dbacb2a33c2e122d5133731f58cc03d015c6a50667dcb06cce9a", - ); - let election_id = JsValue::from_str("9ff8a69d-fa1b-4cc8-a7f0-507b57d0196e"); + // Encode and sign a plaintext ballot using existing test data + let decoded_contests_json = + JSON::parse(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let encode_result = + encode_plaintext_contest_js(decoded_contests_json, election_json); + assert!(encode_result.is_ok(), "Encoding should succeed"); + let auditable_ballot = encode_result.unwrap(); + + // Get the ballot hash + let hash_result = + hash_auditable_plaintext_ballot_js(auditable_ballot.clone()); + assert!(hash_result.is_ok(), "Hashing should succeed"); + let ballot_hash: String = + serde_wasm_bindgen::from_value(hash_result.unwrap()).unwrap(); - // Change to a valid signature for another ballot - let mut ballot_value: Value = - serde_json::from_str(VALID_BALLOT_JSON).unwrap(); - ballot_value["voter_ballot_signature"] = Value::String("pi8aqhz3a/zCoCNE8x8hASwQfH+LmDB/KzThhD3MORliVcmZAej/ldanmL00mf0pgvft+8vaSYR8TqW+LYGLDQ==".to_string()); + let ballot_id = JsValue::from_str(&ballot_hash); + let election_id = JsValue::from_str("f2f1065e-b784-46d1-b81a-c71bfeb9ad55"); - let auditable_multi_ballot_json = - JSON::parse(&ballot_value.to_string()).unwrap(); + // Sign the ballot + let sign_result = + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js( + ballot_id.clone(), + election_id.clone(), + auditable_ballot.clone(), + ); + assert!(sign_result.is_ok(), "Signing should succeed"); - let result = verify_ballot_signature_js( + let signed_content: Value = + serde_wasm_bindgen::from_value(sign_result.unwrap()).unwrap(); + + // Create ballot with tampered signature + let mut auditable_ballot_value: Value = + serde_wasm_bindgen::from_value(auditable_ballot).unwrap(); + auditable_ballot_value["voter_signing_pk"] = + signed_content["public_key"].clone(); + // Use a different (invalid) signature + auditable_ballot_value["voter_ballot_signature"] = + Value::String("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()); + + let tampered_ballot = + JSON::parse(&auditable_ballot_value.to_string()).unwrap(); + + // Verification should fail + let result = verify_plaintext_ballot_signature_js( ballot_id, election_id, - auditable_multi_ballot_json, + tampered_ballot, ); assert!( @@ -69,24 +147,511 @@ fn test_verify_fails_on_bad_signature() { "Verification should fail due to bad signature" ); let error_string = result.err().unwrap().as_string().unwrap(); - assert_eq!(error_string, "Error verifying the ballot: Failed to verify signature: ecdsa error: signature error: Verification equation was not satisfied"); + assert!( + error_string.contains("Failed to verify signature") + || error_string.contains("Verification equation was not satisfied") + || error_string.contains("Failed to deserialize signature"), + "Error message should mention failed verification. Got: {}", + error_string + ); } #[wasm_bindgen_test] fn test_fails_on_malformed_auditable_ballot_json() { - let ballot_id = JsValue::from_str( - "e1c33f34f847dbacb2a33c2e122d5133731f58cc03d015c6a50667dcb06cce9a", - ); - let election_id = JsValue::from_str("9ff8a69d-fa1b-4cc8-a7f0-507b57d0196e"); - let auditable_multi_ballot_json = JsValue::from_str("{ not valid json }"); + let ballot_id = JsValue::from_str("test-ballot-id"); + let election_id = JsValue::from_str("f2f1065e-b784-46d1-b81a-c71bfeb9ad55"); + let auditable_plaintext_ballot_json = + JsValue::from_str("{ not valid json }"); - let result = verify_ballot_signature_js( + let result = verify_plaintext_ballot_signature_js( ballot_id, election_id, - auditable_multi_ballot_json, + auditable_plaintext_ballot_json, ); assert!(result.is_err(), "Should fail on auditable ballot parsing"); let error_string = result.err().unwrap().as_string().unwrap(); - assert!(error_string.contains("Error deserializing auditable multi ballot")); + assert!( + error_string.contains("Failed to parse") + || error_string.contains("Error parsing") + || error_string.contains("Error deserializing"), + "Error should mention parsing failure. Got: {}", + error_string + ); +} + +// Test data for plaintext ballot tests +const PLAINTEXT_ELECTION_JSON: &str = r#"{ + "id":"a12b9343-466e-429f-8ab4-99f6e32bf265", + "tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5", + "election_event_id":"33f18502-a67c-4853-8333-a58630663559", + "election_id":"f2f1065e-b784-46d1-b81a-c71bfeb9ad55", + "description":"Test election for plaintext ballots", + "public_key":{ + "public_key":"/jXUkdSIgz8mXLZ4BIDPQzDx7ZFFIG3MWuacDLyhyhoCAAAAGORKDU/t+8fKNkZMFfXl1IMM+/0VmINTZCcbalZ/NSUi5SbzUTlyzh25lMuVALwvC/lk3j6SHn6BotYphk0QMA", + "is_demo":true + }, + "area_id":"2f312a36-f39c-46e4-9670-1d1ce4625745", + "status":null, + "contests":[ + { + "id":"69f2f987-460c-48ac-ac7a-4d44d99b37e6", + "tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5", + "election_event_id":"33f18502-a67c-4853-8333-a58630663559", + "election_id":"f2f1065e-b784-46d1-b81a-c71bfeb9ad55", + "name":"Test Contest", + "description":"Choose an option", + "max_votes":1, + "min_votes":1, + "winning_candidates_num":1, + "voting_type":"first-past-the-post", + "counting_algorithm":"plurality-at-large", + "is_encrypted":false, + "candidates":[ + { + "id":"a24303de-5798-47cd-9b3e-4f391d1bae7b", + "tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5", + "election_event_id":"33f18502-a67c-4853-8333-a58630663559", + "election_id":"f2f1065e-b784-46d1-b81a-c71bfeb9ad55", + "contest_id":"69f2f987-460c-48ac-ac7a-4d44d99b37e6", + "name":"Option A", + "description":"First option", + "candidate_type":null, + "presentation":null + }, + { + "id":"d9249345-11be-4652-ad04-298d70931610", + "tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5", + "election_event_id":"33f18502-a67c-4853-8333-a58630663559", + "election_id":"f2f1065e-b784-46d1-b81a-c71bfeb9ad55", + "contest_id":"69f2f987-460c-48ac-ac7a-4d44d99b37e6", + "name":"Option B", + "description":"Second option", + "candidate_type":null, + "presentation":null + }, + { + "id":"1822089d-ae17-4a03-8935-25164b3f2142", + "tenant_id":"90505c8a-23a9-4cdf-a26b-4e19f6a097d5", + "election_event_id":"33f18502-a67c-4853-8333-a58630663559", + "election_id":"f2f1065e-b784-46d1-b81a-c71bfeb9ad55", + "contest_id":"69f2f987-460c-48ac-ac7a-4d44d99b37e6", + "name":"Option C", + "description":"Third option", + "candidate_type":null, + "presentation":null + } + ], + "presentation":null + } + ] +}"#; + +const PLAINTEXT_DECODED_CONTESTS_JSON: &str = r#"[{ + "contest_id":"69f2f987-460c-48ac-ac7a-4d44d99b37e6", + "is_explicit_invalid":false, + "invalid_errors":[], + "invalid_alerts":[], + "choices":[ + {"id":"a24303de-5798-47cd-9b3e-4f391d1bae7b","selected":0}, + {"id":"d9249345-11be-4652-ad04-298d70931610","selected":-1}, + {"id":"1822089d-ae17-4a03-8935-25164b3f2142","selected":-1} + ] +}]"#; + +// Helper function to create an auditable plaintext ballot from decoded contests +fn create_auditable_plaintext_ballot() -> JsValue { + let decoded_contests_json = + JSON::parse(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let result = + encode_plaintext_contest_js(decoded_contests_json, election_json); + assert!( + result.is_ok(), + "Failed to create auditable plaintext ballot: {:?}", + result.err() + ); + result.unwrap() +} + +#[wasm_bindgen_test] +fn test_encode_plaintext_contest_success() { + let decoded_contests_json = + JSON::parse(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let result = + encode_plaintext_contest_js(decoded_contests_json, election_json); + + assert!( + result.is_ok(), + "encode_plaintext_contest_js should succeed. Error: {:?}", + result.err() + ); + + // Verify the result has expected structure + let auditable_ballot: Value = + serde_wasm_bindgen::from_value(result.unwrap()).unwrap(); + assert!( + auditable_ballot.get("version").is_some(), + "Should have version field" + ); + assert!( + auditable_ballot.get("issue_date").is_some(), + "Should have issue_date field" + ); + assert!( + auditable_ballot.get("config").is_some(), + "Should have config field" + ); + assert!( + auditable_ballot.get("contests").is_some(), + "Should have contests field" + ); + assert!( + auditable_ballot.get("ballot_hash").is_some(), + "Should have ballot_hash field" + ); +} + +#[wasm_bindgen_test] +fn test_encode_plaintext_contest_fails_on_invalid_contests() { + let invalid_contests_json = + JSON::parse(r#"[{"invalid": "data"}]"#).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let result = + encode_plaintext_contest_js(invalid_contests_json, election_json); + + assert!(result.is_err(), "Should fail on invalid decoded contests"); +} + +#[wasm_bindgen_test] +fn test_decode_auditable_plaintext_ballot_success() { + let auditable_ballot = create_auditable_plaintext_ballot(); + + let result = decode_auditable_plaintext_ballot_js(auditable_ballot); + + assert!( + result.is_ok(), + "decode_auditable_plaintext_ballot_js should succeed. Error: {:?}", + result.err() + ); + + // Verify the decoded contests structure + let decoded_contests: Value = + serde_wasm_bindgen::from_value(result.unwrap()).unwrap(); + assert!(decoded_contests.is_array(), "Result should be an array"); + let contests_array = decoded_contests.as_array().unwrap(); + assert_eq!(contests_array.len(), 1, "Should have one contest"); +} + +#[wasm_bindgen_test] +fn test_encode_decode_plaintext_roundtrip() { + // Encode the plaintext ballot + let decoded_contests_json = + JSON::parse(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let encode_result = + encode_plaintext_contest_js(decoded_contests_json, election_json); + assert!(encode_result.is_ok(), "Encoding should succeed"); + let auditable_ballot = encode_result.unwrap(); + + // Decode back + let decode_result = decode_auditable_plaintext_ballot_js(auditable_ballot); + assert!(decode_result.is_ok(), "Decoding should succeed"); + + // Verify structure matches original + let decoded_contests: Value = + serde_wasm_bindgen::from_value(decode_result.unwrap()).unwrap(); + let original_contests: Value = + serde_json::from_str(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + + let decoded_array = decoded_contests.as_array().unwrap(); + let original_array = original_contests.as_array().unwrap(); + assert_eq!( + decoded_array.len(), + original_array.len(), + "Should have same number of contests" + ); +} + +#[wasm_bindgen_test] +fn test_to_hashable_plaintext_ballot_success() { + let auditable_ballot = create_auditable_plaintext_ballot(); + + let result = to_hashable_plaintext_ballot_js(auditable_ballot); + + assert!( + result.is_ok(), + "to_hashable_plaintext_ballot_js should succeed. Error: {:?}", + result.err() + ); + + // Verify the hashable ballot structure + let hashable_ballot: Value = + serde_wasm_bindgen::from_value(result.unwrap()).unwrap(); + assert!( + hashable_ballot.get("version").is_some(), + "Should have version field" + ); + assert!( + hashable_ballot.get("issue_date").is_some(), + "Should have issue_date field" + ); + assert!( + hashable_ballot.get("contests").is_some(), + "Should have contests field" + ); + assert!( + hashable_ballot.get("config").is_some(), + "Should have config field" + ); + assert!( + hashable_ballot.get("ballot_style_hash").is_some(), + "Should have ballot_style_hash field" + ); +} + +#[wasm_bindgen_test] +fn test_to_hashable_plaintext_ballot_fails_on_malformed_input() { + let malformed_ballot = JsValue::from_str("{ not valid json }"); + + let result = to_hashable_plaintext_ballot_js(malformed_ballot); + + assert!(result.is_err(), "Should fail on malformed input"); + let error_string = result.err().unwrap().as_string().unwrap(); + assert!(error_string.contains("Failed to parse auditable plaintext ballot")); +} + +#[wasm_bindgen_test] +fn test_hash_auditable_plaintext_ballot_success() { + let auditable_ballot = create_auditable_plaintext_ballot(); + + let result = hash_auditable_plaintext_ballot_js(auditable_ballot); + + assert!( + result.is_ok(), + "hash_auditable_plaintext_ballot_js should succeed. Error: {:?}", + result.err() + ); + + // Verify the hash is a non-empty string + let hash: String = serde_wasm_bindgen::from_value(result.unwrap()).unwrap(); + assert!(!hash.is_empty(), "Hash should not be empty"); + assert_eq!(hash.len(), 64, "Hash should be 64 characters (SHA-256 hex)"); +} + +#[wasm_bindgen_test] +fn test_hash_auditable_plaintext_ballot_deterministic() { + // Create two identical ballots and verify they produce the same hash + let auditable_ballot1 = create_auditable_plaintext_ballot(); + let auditable_ballot2 = create_auditable_plaintext_ballot(); + + let result1 = hash_auditable_plaintext_ballot_js(auditable_ballot1); + let result2 = hash_auditable_plaintext_ballot_js(auditable_ballot2); + + assert!( + result1.is_ok() && result2.is_ok(), + "Both hashing operations should succeed" + ); + + let hash1: String = + serde_wasm_bindgen::from_value(result1.unwrap()).unwrap(); + let hash2: String = + serde_wasm_bindgen::from_value(result2.unwrap()).unwrap(); + + assert_eq!( + hash1, hash2, + "Identical ballots should produce identical hashes" + ); +} + +#[wasm_bindgen_test] +fn test_sign_plaintext_ballot_success() { + let auditable_ballot = create_auditable_plaintext_ballot(); + let ballot_id = JsValue::from_str("test-ballot-id-12345"); + let election_id = JsValue::from_str("f2f1065e-b784-46d1-b81a-c71bfeb9ad55"); + + let result = + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js( + ballot_id, + election_id, + auditable_ballot, + ); + + assert!( + result.is_ok(), + "sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js should succeed. Error: {:?}", + result.err() + ); + + // Verify the signed content structure + let signed_content: Value = + serde_wasm_bindgen::from_value(result.unwrap()).unwrap(); + assert!( + signed_content.get("public_key").is_some(), + "Should have public_key field" + ); + assert!( + signed_content.get("signature").is_some(), + "Should have signature field" + ); +} + +#[wasm_bindgen_test] +fn test_sign_and_verify_plaintext_ballot_roundtrip() { + // First encode the ballot + let decoded_contests_json = + JSON::parse(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let encode_result = + encode_plaintext_contest_js(decoded_contests_json, election_json); + assert!(encode_result.is_ok(), "Encoding should succeed"); + let auditable_ballot = encode_result.unwrap(); + + // Get the ballot hash for use as ballot_id + let hash_result = + hash_auditable_plaintext_ballot_js(auditable_ballot.clone()); + assert!(hash_result.is_ok(), "Hashing should succeed"); + let ballot_hash: String = + serde_wasm_bindgen::from_value(hash_result.unwrap()).unwrap(); + + let ballot_id = JsValue::from_str(&ballot_hash); + let election_id = JsValue::from_str("f2f1065e-b784-46d1-b81a-c71bfeb9ad55"); + + // Sign the ballot + let sign_result = + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js( + ballot_id.clone(), + election_id.clone(), + auditable_ballot.clone(), + ); + assert!( + sign_result.is_ok(), + "Signing should succeed. Error: {:?}", + sign_result.err() + ); + + // Get the signed content + let signed_content: Value = + serde_wasm_bindgen::from_value(sign_result.unwrap()).unwrap(); + + // Update the auditable ballot with signature information + let mut auditable_ballot_value: Value = + serde_wasm_bindgen::from_value(auditable_ballot).unwrap(); + auditable_ballot_value["voter_signing_pk"] = + signed_content["public_key"].clone(); + auditable_ballot_value["voter_ballot_signature"] = + signed_content["signature"].clone(); + + // Convert back to JsValue + let signed_auditable_ballot = + JSON::parse(&auditable_ballot_value.to_string()).unwrap(); + + // Verify the signature + let verify_result = verify_plaintext_ballot_signature_js( + ballot_id, + election_id, + signed_auditable_ballot, + ); + + assert!( + verify_result.is_ok(), + "Verification should succeed. Error: {:?}", + verify_result.err() + ); + + let is_verified: bool = + serde_wasm_bindgen::from_value(verify_result.unwrap()).unwrap(); + assert!(is_verified, "Signature verification should return true"); +} + +#[wasm_bindgen_test] +fn test_verify_plaintext_ballot_fails_with_tampered_signature() { + // First encode and sign the ballot + let decoded_contests_json = + JSON::parse(PLAINTEXT_DECODED_CONTESTS_JSON).unwrap(); + let election_json = JSON::parse(PLAINTEXT_ELECTION_JSON).unwrap(); + + let encode_result = + encode_plaintext_contest_js(decoded_contests_json, election_json); + assert!(encode_result.is_ok(), "Encoding should succeed"); + let auditable_ballot = encode_result.unwrap(); + + // Get the ballot hash + let hash_result = + hash_auditable_plaintext_ballot_js(auditable_ballot.clone()); + assert!(hash_result.is_ok(), "Hashing should succeed"); + let ballot_hash: String = + serde_wasm_bindgen::from_value(hash_result.unwrap()).unwrap(); + + let ballot_id = JsValue::from_str(&ballot_hash); + let election_id = JsValue::from_str("f2f1065e-b784-46d1-b81a-c71bfeb9ad55"); + + // Sign the ballot + let sign_result = + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js( + ballot_id.clone(), + election_id.clone(), + auditable_ballot.clone(), + ); + assert!(sign_result.is_ok(), "Signing should succeed"); + + let signed_content: Value = + serde_wasm_bindgen::from_value(sign_result.unwrap()).unwrap(); + + // Create ballot with tampered signature + let mut auditable_ballot_value: Value = + serde_wasm_bindgen::from_value(auditable_ballot).unwrap(); + auditable_ballot_value["voter_signing_pk"] = + signed_content["public_key"].clone(); + // Use a different (invalid) signature + auditable_ballot_value["voter_ballot_signature"] = + Value::String("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()); + + let tampered_ballot = + JSON::parse(&auditable_ballot_value.to_string()).unwrap(); + + // Verification should fail + let verify_result = verify_plaintext_ballot_signature_js( + ballot_id, + election_id, + tampered_ballot, + ); + + assert!( + verify_result.is_err(), + "Verification should fail with tampered signature" + ); +} + +#[wasm_bindgen_test] +fn test_verify_plaintext_ballot_without_signature() { + let auditable_ballot = create_auditable_plaintext_ballot(); + let ballot_id = JsValue::from_str("test-ballot-id"); + let election_id = JsValue::from_str("f2f1065e-b784-46d1-b81a-c71bfeb9ad55"); + + // Verify a ballot that has no signature should return false (not error) + let result = verify_plaintext_ballot_signature_js( + ballot_id, + election_id, + auditable_ballot, + ); + + assert!( + result.is_ok(), + "Verification of unsigned ballot should not error. Error: {:?}", + result.err() + ); + + let is_verified: bool = + serde_wasm_bindgen::from_value(result.unwrap()).unwrap(); + assert!( + !is_verified, + "Unsigned ballot verification should return false" + ); } diff --git a/packages/ui-core/rust/sequent-core-0.1.0.tgz b/packages/ui-core/rust/sequent-core-0.1.0.tgz index 53f0a003975..8e4b25bca15 100644 Binary files a/packages/ui-core/rust/sequent-core-0.1.0.tgz and b/packages/ui-core/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/ui-core/src/services/wasm.ts b/packages/ui-core/src/services/wasm.ts index f2a0438d8b7..d4c49f0e72e 100644 --- a/packages/ui-core/src/services/wasm.ts +++ b/packages/ui-core/src/services/wasm.ts @@ -16,12 +16,16 @@ import { sort_candidates_list_js, decode_auditable_ballot_js, decode_auditable_multi_ballot_js, + decode_auditable_plaintext_ballot_js, to_hashable_ballot_js, to_hashable_multi_ballot_js, + to_hashable_plaintext_ballot_js, hash_auditable_ballot_js, hash_auditable_multi_ballot_js, + hash_auditable_plaintext_ballot_js, encrypt_decoded_contest_js, encrypt_decoded_multi_contest_js, + encode_plaintext_contest_js, test_contest_reencoding_js, is_preferential_js, test_multi_contest_reencoding_js, @@ -29,11 +33,13 @@ import { check_is_blank_js, sign_hashable_ballot_with_ephemeral_voter_signing_key_js, sign_hashable_multi_ballot_with_ephemeral_voter_signing_key_js, + sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js, IDecodedVoteContest, check_voting_not_allowed_next, check_voting_error_dialog, verify_ballot_signature_js, verify_multi_ballot_signature_js, + verify_plaintext_ballot_signature_js, } from "sequent-core" import { CandidatesOrder, @@ -41,12 +47,14 @@ import { ElectionsOrder, IAuditableSingleBallot, IAuditableMultiBallot, + IAuditablePlaintextBallot, IBallotStyle, ICandidate, IContest, IElection, IHashableSingleBallot, IHashableMultiBallot, + IHashablePlaintextBallot, ISignedContent, ICountingAlgorithm, } from ".." @@ -163,6 +171,17 @@ export const toHashableMultiBallot = ( } } +export const toHashablePlaintextBallot = ( + auditablePlaintextBallot: IAuditablePlaintextBallot +): IHashablePlaintextBallot => { + try { + return to_hashable_plaintext_ballot_js(auditablePlaintextBallot) + } catch (error) { + console.log(error) + throw error + } +} + export const hashBallot = (auditableBallot: IAuditableSingleBallot): string => { try { return hash_auditable_ballot_js(auditableBallot) @@ -181,6 +200,17 @@ export const hashMultiBallot = (auditableMultiBallot: IAuditableMultiBallot): st } } +export const hashPlaintextBallot = ( + auditablePlaintextBallot: IAuditablePlaintextBallot +): string => { + try { + return hash_auditable_plaintext_ballot_js(auditablePlaintextBallot) + } catch (error) { + console.log(error) + throw error + } +} + export const encryptBallotSelection = ( ballotSelection: BallotSelection, election: IBallotStyle @@ -205,6 +235,18 @@ export const encryptMultiBallotSelection = ( } } +export const encodePlaintextBallotSelection = ( + ballotSelection: BallotSelection, + election: IBallotStyle +): IAuditablePlaintextBallot => { + try { + return encode_plaintext_contest_js(ballotSelection, election) + } catch (error) { + console.log(error) + throw error + } +} + export const signHashableBallot = ( ballot_id: string, election_id: string, @@ -239,6 +281,23 @@ export const signHashableMultiBallot = ( } } +export const signHashablePlaintextBallot = ( + ballot_id: string, + election_id: string, + content: IAuditablePlaintextBallot +): ISignedContent => { + try { + return sign_hashable_plaintext_ballot_with_ephemeral_voter_signing_key_js( + ballot_id, + election_id, + content + ) + } catch (error) { + console.log(error) + throw error + } +} + /* * Encodes and decodes the contest selection. * The result is getting the ballot selection back from sequent-core, @@ -313,6 +372,18 @@ export const decodeAuditableMultiBallot = ( } } +export const decodeAuditablePlaintextBallot = ( + auditableBallot: IAuditablePlaintextBallot +): Array | null => { + try { + let decodedBallot = decode_auditable_plaintext_ballot_js(auditableBallot) + return decodedBallot as Array + } catch (error) { + console.log(error) + throw error + } +} + export const checkIsBlank = (contest: IDecodedVoteContest): boolean | null => { try { let is_blank: boolean = check_is_blank_js(contest) @@ -351,6 +422,24 @@ export const verifyMultiBallotSignature = ( } } +export const verifyPlaintextBallotSignature = ( + ballot_id: string, + election_id: string, + content: IAuditablePlaintextBallot +): boolean | null => { + try { + let isVerified: boolean = verify_plaintext_ballot_signature_js( + ballot_id, + election_id, + content + ) + return isVerified + } catch (error) { + console.log(error) + throw error + } +} + export const check_voting_not_allowed_next_bool = ( contests: IContest[] | undefined, decodedContests: Record diff --git a/packages/ui-core/src/types/CoreTypes.ts b/packages/ui-core/src/types/CoreTypes.ts index d63847534a3..a14d6cb92e1 100644 --- a/packages/ui-core/src/types/CoreTypes.ts +++ b/packages/ui-core/src/types/CoreTypes.ts @@ -157,7 +157,7 @@ export interface IBallotStyle { } export interface IPublicKeyConfig { - public_key: string + public_key?: string is_demo: boolean } @@ -175,6 +175,9 @@ export interface IAuditableSingleBallot extends IAuditableBallot { export interface IAuditableMultiBallot extends IAuditableBallot { contests: string } +export interface IAuditablePlaintextBallot extends IAuditableBallot { + contests: Array +} export interface IHashableBallot { version: number @@ -191,6 +194,10 @@ export interface IHashableMultiBallot extends IHashableBallot { contests: string } +export interface IHashablePlaintextBallot extends IHashableBallot { + contests: Array +} + export interface ISignedContent { public_key: string signature: string diff --git a/packages/ui-core/src/types/ElectionEventPresentation.ts b/packages/ui-core/src/types/ElectionEventPresentation.ts index 6b992433d6b..d71c86e169c 100644 --- a/packages/ui-core/src/types/ElectionEventPresentation.ts +++ b/packages/ui-core/src/types/ElectionEventPresentation.ts @@ -53,6 +53,7 @@ export enum EElectionEventDecodedBallots { export enum EElectionEventContestEncryptionPolicy { MULTIPLE_CONTESTS = "multiple-contests", SINGLE_CONTEST = "single-contest", + PLAINTEXT = "plaintext", } export enum EElectionEventPublishPolicy { diff --git a/packages/velvet/src/fixtures/ballot_styles.rs b/packages/velvet/src/fixtures/ballot_styles.rs index 6f228151fcd..789b0ed8b15 100644 --- a/packages/velvet/src/fixtures/ballot_styles.rs +++ b/packages/velvet/src/fixtures/ballot_styles.rs @@ -22,7 +22,7 @@ pub fn get_ballot_style_1( num_allowed_revotes: Some(1), description: Some("Write-ins simple".into()), public_key: Some(PublicKeyConfig { - public_key: "ajR/I9RqyOwbpsVRucSNOgXVLCvLpfQxCgPoXGQ2RF4".into(), + public_key: Some("ajR/I9RqyOwbpsVRucSNOgXVLCvLpfQxCgPoXGQ2RF4".into()), is_demo: false, }), area_id: area_id.to_string(), @@ -57,7 +57,7 @@ pub fn generate_ballot_style( num_allowed_revotes: Some(1), description: Some("Write-ins simple".into()), public_key: Some(PublicKeyConfig { - public_key: "ajR/I9RqyOwbpsVRucSNOgXVLCvLpfQxCgPoXGQ2RF4".into(), + public_key: Some("ajR/I9RqyOwbpsVRucSNOgXVLCvLpfQxCgPoXGQ2RF4".into()), is_demo: false, }), area_id: area_id.to_string(), diff --git a/packages/voting-portal/graphql.schema.json b/packages/voting-portal/graphql.schema.json index ab1b071bacd..030f77e4f73 100644 --- a/packages/voting-portal/graphql.schema.json +++ b/packages/voting-portal/graphql.schema.json @@ -116895,13 +116895,9 @@ "description": null, "args": [], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "uuid", - "ofType": null - } + "kind": "SCALAR", + "name": "uuid", + "ofType": null }, "isDeprecated": false, "deprecationReason": null diff --git a/packages/voting-portal/rust/sequent-core-0.1.0.tgz b/packages/voting-portal/rust/sequent-core-0.1.0.tgz index 53f0a003975..8e4b25bca15 100644 Binary files a/packages/voting-portal/rust/sequent-core-0.1.0.tgz and b/packages/voting-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/voting-portal/src/gql/graphql.ts b/packages/voting-portal/src/gql/graphql.ts index 56354ce4270..455b59a2ede 100644 --- a/packages/voting-portal/src/gql/graphql.ts +++ b/packages/voting-portal/src/gql/graphql.ts @@ -16352,7 +16352,7 @@ export type Sequent_Backend_Tally_Session = { execution_status?: Maybe; id: Scalars['uuid']['output']; is_execution_completed: Scalars['Boolean']['output']; - keys_ceremony_id: Scalars['uuid']['output']; + keys_ceremony_id?: Maybe; labels?: Maybe; last_updated_at?: Maybe; permission_label?: Maybe>; diff --git a/packages/voting-portal/src/routes/AuditScreen.tsx b/packages/voting-portal/src/routes/AuditScreen.tsx index e87df0ec6ce..f0f484710f9 100644 --- a/packages/voting-portal/src/routes/AuditScreen.tsx +++ b/packages/voting-portal/src/routes/AuditScreen.tsx @@ -20,6 +20,7 @@ import { downloadBlob, IAuditableSingleBallot, IAuditableMultiBallot, + IAuditablePlaintextBallot, EElectionEventContestEncryptionPolicy, } from "@sequentech/ui-core" import {styled} from "@mui/material/styles" @@ -35,6 +36,7 @@ import {Typography} from "@mui/material" import {useAppSelector} from "../store/hooks" import {selectAuditableBallot} from "../store/auditableBallots/auditableBallotsSlice" import {provideBallotService} from "../services/BallotService" +import {getBallotStrategy} from "../services/BallotStrategy" import {SettingsContext} from "../providers/SettingsContextProvider" import {useRootBackLink} from "../hooks/root-back-link" import StyledLinkContainer from "../components/Link" @@ -116,14 +118,13 @@ const AuditScreen: React.FC = () => { const {t} = useTranslation() const [openBallotIdHelp, setOpenBallotIdHelp] = useState(false) const [openStep1Help, setOpenStep1Help] = useState(false) - const {hashBallot, hashMultiBallot} = provideBallotService() - const isMultiContest = - auditableBallot?.config.election_event_presentation?.contest_encryption_policy == - EElectionEventContestEncryptionPolicy.MULTIPLE_CONTESTS - const hashedBallot = isMultiContest - ? hashMultiBallot(auditableBallot as IAuditableMultiBallot) - : hashBallot(auditableBallot as IAuditableSingleBallot) - const ballotHash = auditableBallot && hashedBallot + const ballotService = provideBallotService() + + const encryptionPolicy = + auditableBallot?.config.election_event_presentation?.contest_encryption_policy + + const ballotHash = + auditableBallot && getBallotStrategy(encryptionPolicy, ballotService).hash(auditableBallot) const backLink = useRootBackLink() const navigate = useNavigate() const location = useLocation() diff --git a/packages/voting-portal/src/routes/ConfirmationScreen.tsx b/packages/voting-portal/src/routes/ConfirmationScreen.tsx index dcaff179e75..aa4e89bb33b 100644 --- a/packages/voting-portal/src/routes/ConfirmationScreen.tsx +++ b/packages/voting-portal/src/routes/ConfirmationScreen.tsx @@ -2,18 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0-only import {Box, CircularProgress, Typography} from "@mui/material" -import React, {useState, useEffect, useContext, useCallback, useRef, useMemo} from "react" +import React, {useState, useEffect, useContext, useCallback, useRef} from "react" import {useTranslation} from "react-i18next" import {PageLimit, Icon, IconButton, theme, QRCode, Dialog} from "@sequentech/ui-essentials" -import { - stringToHtml, - IElectionEventPresentation, - EVotingStatus, - IAuditableMultiBallot, - IAuditableSingleBallot, - EElectionEventContestEncryptionPolicy, - IElection, -} from "@sequentech/ui-core" +import {stringToHtml, IElectionEventPresentation, EVotingStatus} from "@sequentech/ui-core" import {styled} from "@mui/material/styles" import {faPrint, faCircleQuestion, faCheck} from "@fortawesome/free-solid-svg-icons" import Button from "@mui/material/Button" @@ -24,7 +16,6 @@ import {useAppDispatch, useAppSelector} from "../store/hooks" import {selectAuditableBallot} from "../store/auditableBallots/auditableBallotsSlice" import {canVoteSomeElection} from "../store/castVotes/castVotesSlice" import {selectElectionEventById} from "../store/electionEvents/electionEventsSlice" -import {IElectionExtended} from "../store/elections/electionsSlice" import {TenantEventType} from ".." import {clearBallot} from "../store/ballotSelections/ballotSelectionsSlice" import { @@ -43,13 +34,11 @@ import {VotingPortalError, VotingPortalErrorType} from "../services/VotingPortal import {GetDocumentQuery, GetElectionsQuery} from "../gql/graphql" import {GET_ELECTIONS} from "../queries/GetElections" import {downloadUrl} from "@sequentech/ui-core" -import { - ConfirmationScreenData, - selectConfirmationScreenData, -} from "../store/castVotes/confirmationScreenDataSlice" +import {selectConfirmationScreenData} from "../store/castVotes/confirmationScreenDataSlice" import {GetCastVotesQuery} from "../gql/graphql" import {GET_CAST_VOTES} from "../queries/GetCastVotes" import {GET_DOCUMENT} from "../queries/GetDocument" +import {getBallotStrategy} from "../services/BallotStrategy" const StyledTitle = styled(Typography)` margin-top: 25.5px; @@ -185,7 +174,7 @@ const ActionButtons: React.FC = ({ (item) => item.status.voting_status === EVotingStatus.OPEN ) - const {data: castVotes, error: errorCastVote} = useQuery(GET_CAST_VOTES, { + const {data: castVotes} = useQuery(GET_CAST_VOTES, { skip: globalSettings.DISABLE_AUTH || !isGoldenAuth, }) @@ -352,7 +341,7 @@ const ConfirmationScreen: React.FC = () => { const [openBallotIdHelp, setOpenBallotIdHelp] = useState(false) const [openConfirmationHelp, setOpenConfirmationHelp] = useState(false) const [openDemoBallotUrlHelp, setDemoBallotUrlHelp] = useState(false) - const {hashBallot, hashMultiBallot} = provideBallotService() + const ballotService = provideBallotService() const oneBallotStyle = useAppSelector(selectFirstBallotStyle) const getBallotId = (): { @@ -371,12 +360,12 @@ const ConfirmationScreen: React.FC = () => { } } else { console.log("auditableBallot normal flow") - const isMultiContest = - auditableBallot?.config.election_event_presentation?.contest_encryption_policy == - EElectionEventContestEncryptionPolicy.MULTIPLE_CONTESTS - const hashableBallot = isMultiContest - ? hashMultiBallot(auditableBallot as IAuditableMultiBallot) - : hashBallot(auditableBallot as IAuditableSingleBallot) + const encryptionPolicy = + auditableBallot?.config.election_event_presentation?.contest_encryption_policy + + const strategy = getBallotStrategy(encryptionPolicy, ballotService) + const hashableBallot = strategy.hash(auditableBallot) + const ballotIdStored = (auditableBallot && hashableBallot) || undefined const isDemoStored = oneBallotStyle?.ballot_eml.public_key?.is_demo return {ballotIdStored, isDemoStored} diff --git a/packages/voting-portal/src/routes/ReviewScreen.tsx b/packages/voting-portal/src/routes/ReviewScreen.tsx index d008a15a3e1..683b957c6c5 100644 --- a/packages/voting-portal/src/routes/ReviewScreen.tsx +++ b/packages/voting-portal/src/routes/ReviewScreen.tsx @@ -31,6 +31,7 @@ import { EGraphQLErrorCode, IAuditableSingleBallot, IAuditableMultiBallot, + IAuditablePlaintextBallot, ECastVoteGoldLevelPolicy, EElectionEventContestEncryptionPolicy, IHashableBallot, @@ -52,6 +53,7 @@ import {INSERT_CAST_VOTE} from "../queries/InsertCastVote" import {GetElectionEventQuery, InsertCastVoteMutation, GetElectionsQuery} from "../gql/graphql" import {GET_ELECTIONS} from "../queries/GetElections" import {provideBallotService} from "../services/BallotService" +import {getBallotStrategy} from "../services/BallotStrategy" import {ICastVote, addCastVotes} from "../store/castVotes/castVotesSlice" import {TenantEventType} from ".." import {useRootBackLink} from "../hooks/root-back-link" @@ -67,8 +69,10 @@ import { sortContestList, hashBallot, hashMultiBallot, + hashPlaintextBallot, IHashableSingleBallot, IHashableMultiBallot, + IHashablePlaintextBallot, } from "@sequentech/ui-core" import {SettingsContext} from "../providers/SettingsContextProvider" import {AuthContext} from "../providers/AuthContextProvider" @@ -324,7 +328,7 @@ const ActionButtons: React.FC = ({ const isCastingBallot = useRef(false) const [isConfirmCastVoteModal, setConfirmCastVoteModal] = React.useState(false) const {tenantId, eventId} = useParams() - const {toHashableBallot, toHashableMultiBallot} = provideBallotService() + const ballotService = provideBallotService() const submit = useSubmit() const isDemo = !!ballotStyle?.ballot_eml?.public_key?.is_demo const {globalSettings} = useContext(SettingsContext) @@ -398,11 +402,15 @@ const ActionButtons: React.FC = ({ } } - let hashableBallot: IHashableSingleBallot | IHashableMultiBallot | undefined + let hashableBallot: IHashableBallot | undefined try { - hashableBallot = isMultiContest - ? toHashableMultiBallot(auditableBallot as IAuditableMultiBallot) - : toHashableBallot(auditableBallot as IAuditableSingleBallot) + const encryptionPolicy = + auditableBallot?.config.election_event_presentation?.contest_encryption_policy + + const strategy = getBallotStrategy(encryptionPolicy, ballotService) + + // Replaces the large switch/IIFE block + hashableBallot = strategy.toHashable(auditableBallot) } catch (error) { isCastingBallot.current = false console.error(error) @@ -501,7 +509,8 @@ export const ReviewScreen: React.FC = () => { const [auditBallotHelp, setAuditBallotHelp] = useState(false) const [openBallotIdHelp, setOpenBallotIdHelp] = useState(false) const [openReviewScreenHelp, setReviewScreenHelp] = useState(false) - const {interpretContestSelection, interpretMultiContestSelection} = provideBallotService() + const ballotService = provideBallotService() + const {interpretContestSelection, interpretMultiContestSelection} = ballotService const {t} = useTranslation() const backLink = useRootBackLink() const navigate = useNavigate() @@ -553,11 +562,11 @@ export const ReviewScreen: React.FC = () => { const isMultiContest = auditableBallot?.config.election_event_presentation?.contest_encryption_policy == EElectionEventContestEncryptionPolicy.MULTIPLE_CONTESTS - const hashableBallot = auditableBallot - ? isMultiContest - ? hashMultiBallot(auditableBallot as IAuditableMultiBallot) - : hashBallot(auditableBallot as IAuditableSingleBallot) - : undefined + const hashableBallot = useMemo(() => { + if (!auditableBallot) return undefined + const policy = auditableBallot.config.election_event_presentation?.contest_encryption_policy + return getBallotStrategy(policy, ballotService).hash(auditableBallot) + }, [auditableBallot]) const ballotId = useMemo(() => { return auditableBallot && hashableBallot ? hashableBallot : undefined diff --git a/packages/voting-portal/src/routes/VotingScreen.tsx b/packages/voting-portal/src/routes/VotingScreen.tsx index b33861e73ee..837af74cd18 100644 --- a/packages/voting-portal/src/routes/VotingScreen.tsx +++ b/packages/voting-portal/src/routes/VotingScreen.tsx @@ -16,9 +16,11 @@ import { IContest, IAuditableMultiBallot, IAuditableSingleBallot, + IAuditablePlaintextBallot, EElectionEventContestEncryptionPolicy, EVoterSigningPolicy, BallotSelection, + hashPlaintextBallot, } from "@sequentech/ui-core" import {styled} from "@mui/material/styles" import Typography from "@mui/material/Typography" @@ -33,6 +35,7 @@ import { } from "../store/ballotSelections/ballotSelectionsSlice" import {clearIsVoted, setIsVoted} from "../store/extra/extraSlice" import {provideBallotService} from "../services/BallotService" +import {getBallotStrategy} from "../services/BallotStrategy" import {setAuditableBallot} from "../store/auditableBallots/auditableBallotsSlice" import {Question} from "../components/Question/Question" import {CircularProgress} from "@mui/material" @@ -299,16 +302,7 @@ const VotingScreen: React.FC = () => { const [hasInvalidErrors, setHasInvalidErrors] = useState(false) const [contestsPerPage, setContestsPerPage] = useState([]) - const { - encryptBallotSelection, - encryptMultiBallotSelection, - decodeAuditableBallot, - decodeAuditableMultiBallot, - signHashableMultiBallot, - signHashableBallot, - hashMultiBallot, - hashBallot, - } = provideBallotService() + const ballotService = provideBallotService() const election = useAppSelector(selectElectionById(String(electionId))) const ballotStyle = useAppSelector(selectBallotStyleByElectionId(String(electionId))) @@ -377,26 +371,20 @@ const VotingScreen: React.FC = () => { ballotStyle.ballot_eml.election_event_presentation?.voter_signing_policy === EVoterSigningPolicy.WITH_SIGNATURE - const auditableBallot = isMultiContest - ? encryptMultiBallotSelection(selectionState, ballotStyle.ballot_eml) - : encryptBallotSelection(selectionState, ballotStyle.ballot_eml) + const encryptionPolicy = + ballotStyle.ballot_eml.election_event_presentation?.contest_encryption_policy - let ballotId = isMultiContest - ? hashMultiBallot(auditableBallot as IAuditableMultiBallot) - : hashBallot(auditableBallot as IAuditableSingleBallot) + const strategy = getBallotStrategy(encryptionPolicy, ballotService) + + const auditableBallot = strategy.encrypt(selectionState, ballotStyle.ballot_eml) + const ballotId = strategy.hash(auditableBallot) if (doSignBallot) { - let signedContent = isMultiContest - ? signHashableMultiBallot( - ballotId, - ballotStyle.election_id, - auditableBallot as IAuditableMultiBallot - ) - : signHashableBallot( - ballotId, - ballotStyle.election_id, - auditableBallot as IAuditableSingleBallot - ) + const signedContent = strategy.sign( + ballotId, + ballotStyle.election_id, + auditableBallot + ) auditableBallot.voter_signing_pk = signedContent?.public_key auditableBallot.voter_ballot_signature = signedContent?.signature } @@ -408,9 +396,7 @@ const VotingScreen: React.FC = () => { }) ) - let decodedSelectionState = isMultiContest - ? decodeAuditableMultiBallot(auditableBallot as IAuditableMultiBallot) - : decodeAuditableBallot(auditableBallot as IAuditableSingleBallot) + const decodedSelectionState = strategy.decode(auditableBallot) if (null !== decodedSelectionState) { dispatch( @@ -419,6 +405,8 @@ const VotingScreen: React.FC = () => { ballotSelection: decodedSelectionState, }) ) + } else { + throw new VotingPortalError(VotingPortalErrorType.INTERNAL_ERROR) } submit(null, {method: "post"}) diff --git a/packages/voting-portal/src/services/BallotService.ts b/packages/voting-portal/src/services/BallotService.ts index 1a6e3ca5c87..67d481c61ac 100644 --- a/packages/voting-portal/src/services/BallotService.ts +++ b/packages/voting-portal/src/services/BallotService.ts @@ -4,27 +4,34 @@ import { toHashableBallot, toHashableMultiBallot, + toHashablePlaintextBallot, hashBallot, hashMultiBallot, + hashPlaintextBallot, encryptBallotSelection, encryptMultiBallotSelection, + encodePlaintextBallotSelection, interpretContestSelection, isPreferential, interpretMultiContestSelection, getWriteInAvailableCharacters, decodeAuditableBallot, decodeAuditableMultiBallot, + decodeAuditablePlaintextBallot, checkIsBlank, signHashableBallot, signHashableMultiBallot, + signHashablePlaintextBallot, IDecodedVoteContest, IBallotStyle, IAuditableBallot, IAuditableSingleBallot, IAuditableMultiBallot, + IAuditablePlaintextBallot, IHashableBallot, IHashableSingleBallot, IHashableMultiBallot, + IHashablePlaintextBallot, ISignedContent, IContest, BallotSelection, @@ -34,8 +41,12 @@ import { export interface IBallotService { toHashableBallot: (auditableBallot: IAuditableSingleBallot) => IHashableSingleBallot toHashableMultiBallot: (auditableBallot: IAuditableMultiBallot) => IHashableMultiBallot + toHashablePlaintextBallot: ( + auditableBallot: IAuditablePlaintextBallot + ) => IHashablePlaintextBallot hashBallot: (auditableBallot: IAuditableSingleBallot) => string hashMultiBallot: (auditableBallot: IAuditableMultiBallot) => string + hashPlaintextBallot: (auditableBallot: IAuditablePlaintextBallot) => string encryptBallotSelection: ( ballotSelection: BallotSelection, election: IBallotStyle @@ -44,6 +55,10 @@ export interface IBallotService { ballotSelection: BallotSelection, election: IBallotStyle ) => IAuditableMultiBallot + encodePlaintextBallotSelection: ( + ballotSelection: BallotSelection, + election: IBallotStyle + ) => IAuditablePlaintextBallot interpretContestSelection: ( contestSelection: Array, election: IBallotStyle @@ -52,6 +67,7 @@ export interface IBallotService { contestSelections: Array, election: IBallotStyle ) => Array + // TODO interpretPlaintextContestSelection getWriteInAvailableCharacters: ( contestSelection: IDecodedVoteContest, election: IBallotStyle @@ -62,6 +78,9 @@ export interface IBallotService { decodeAuditableMultiBallot: ( auditableBallot: IAuditableMultiBallot ) => Array | null + decodeAuditablePlaintextBallot: ( + auditableBallot: IAuditablePlaintextBallot + ) => Array | null checkIsBlank: (contest: IDecodedVoteContest) => boolean | null signHashableBallot: ( ballotId: string, @@ -74,22 +93,32 @@ export interface IBallotService { hashableBallot: IAuditableMultiBallot ) => ISignedContent | null isPreferential: (countingAlgorithm?: ICountingAlgorithm) => boolean + signHashablePlaintextBallot: ( + ballotId: string, + electionId: string, + hashableBallot: IAuditablePlaintextBallot + ) => ISignedContent | null } export const provideBallotService = (): IBallotService => ({ toHashableBallot, toHashableMultiBallot, + toHashablePlaintextBallot, hashBallot, hashMultiBallot, + hashPlaintextBallot, encryptBallotSelection, encryptMultiBallotSelection, + encodePlaintextBallotSelection, interpretContestSelection, interpretMultiContestSelection, getWriteInAvailableCharacters, decodeAuditableBallot, decodeAuditableMultiBallot, + decodeAuditablePlaintextBallot, checkIsBlank, signHashableBallot, signHashableMultiBallot, isPreferential, + signHashablePlaintextBallot, }) diff --git a/packages/voting-portal/src/services/BallotStrategy.ts b/packages/voting-portal/src/services/BallotStrategy.ts new file mode 100644 index 00000000000..d94afc32d62 --- /dev/null +++ b/packages/voting-portal/src/services/BallotStrategy.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only +import { + IAuditableBallot, + IAuditableSingleBallot, + IAuditableMultiBallot, + IAuditablePlaintextBallot, + IBallotStyle, + BallotSelection, + ISignedContent, + IDecodedVoteContest, + IHashableBallot, + EElectionEventContestEncryptionPolicy, +} from "@sequentech/ui-core" +import {IBallotService} from "./BallotService" +import {VotingPortalError, VotingPortalErrorType} from "./VotingPortalError" + +// The interface all strategies must follow +export interface IBallotStrategy { + encrypt(selection: BallotSelection, election: IBallotStyle): IAuditableBallot + hash(ballot: IAuditableBallot): string + toHashable(ballot: IAuditableBallot): IHashableBallot + sign(ballotId: string, electionId: string, ballot: IAuditableBallot): ISignedContent | null + decode(ballot: IAuditableBallot): Array | null +} + +// --- Implementation: Single Contest --- +class SingleContestStrategy implements IBallotStrategy { + constructor(private service: IBallotService) {} + + encrypt(selection: BallotSelection, election: IBallotStyle) { + return this.service.encryptBallotSelection(selection, election) + } + + hash(ballot: IAuditableBallot) { + return this.service.hashBallot(ballot as IAuditableSingleBallot) + } + + toHashable(ballot: IAuditableBallot) { + return this.service.toHashableBallot(ballot as IAuditableSingleBallot) + } + + sign(ballotId: string, electionId: string, ballot: IAuditableBallot) { + return this.service.signHashableBallot( + ballotId, + electionId, + ballot as IAuditableSingleBallot + ) + } + + decode(ballot: IAuditableBallot) { + return this.service.decodeAuditableBallot(ballot as IAuditableSingleBallot) + } +} + +// --- Implementation: Multiple Contests --- +class MultiContestStrategy implements IBallotStrategy { + constructor(private service: IBallotService) {} + + encrypt(selection: BallotSelection, election: IBallotStyle) { + return this.service.encryptMultiBallotSelection(selection, election) + } + + hash(ballot: IAuditableBallot) { + return this.service.hashMultiBallot(ballot as IAuditableMultiBallot) + } + + toHashable(ballot: IAuditableBallot) { + return this.service.toHashableMultiBallot(ballot as IAuditableMultiBallot) + } + + sign(ballotId: string, electionId: string, ballot: IAuditableBallot) { + return this.service.signHashableMultiBallot( + ballotId, + electionId, + ballot as IAuditableMultiBallot + ) + } + + decode(ballot: IAuditableBallot) { + return this.service.decodeAuditableMultiBallot(ballot as IAuditableMultiBallot) + } +} + +// --- Implementation: Plaintext --- +class PlaintextStrategy implements IBallotStrategy { + constructor(private service: IBallotService) {} + + encrypt(selection: BallotSelection, election: IBallotStyle) { + return this.service.encodePlaintextBallotSelection(selection, election) + } + + hash(ballot: IAuditableBallot) { + return this.service.hashPlaintextBallot(ballot as IAuditablePlaintextBallot) + } + + toHashable(ballot: IAuditableBallot) { + return this.service.toHashablePlaintextBallot(ballot as IAuditablePlaintextBallot) + } + + sign(ballotId: string, electionId: string, ballot: IAuditableBallot) { + return this.service.signHashablePlaintextBallot( + ballotId, + electionId, + ballot as IAuditablePlaintextBallot + ) + } + + decode(ballot: IAuditableBallot) { + return this.service.decodeAuditablePlaintextBallot(ballot as IAuditablePlaintextBallot) + } +} + +// --- Factory --- +export const getBallotStrategy = ( + policy: EElectionEventContestEncryptionPolicy | undefined, + service: IBallotService +): IBallotStrategy => { + switch (policy) { + case EElectionEventContestEncryptionPolicy.MULTIPLE_CONTESTS: + return new MultiContestStrategy(service) + case EElectionEventContestEncryptionPolicy.PLAINTEXT: + return new PlaintextStrategy(service) + case EElectionEventContestEncryptionPolicy.SINGLE_CONTEST: + return new SingleContestStrategy(service) + default: + throw new VotingPortalError(VotingPortalErrorType.UNABLE_TO_ENCRYPT_BALLOT) + } +} diff --git a/packages/windmill/src/postgres/tally_session.rs b/packages/windmill/src/postgres/tally_session.rs index 646a3cfe4d2..03a33a466f1 100644 --- a/packages/windmill/src/postgres/tally_session.rs +++ b/packages/windmill/src/postgres/tally_session.rs @@ -49,7 +49,10 @@ impl TryFrom for TallySessionWrapper { .collect() }), is_execution_completed: item.try_get("is_execution_completed")?, - keys_ceremony_id: item.try_get::<_, Uuid>("keys_ceremony_id")?.to_string(), + keys_ceremony_id: item + .try_get::<_, Uuid>("keys_ceremony_id") + .map(|id| id.to_string()) + .ok(), execution_status: item.try_get("execution_status")?, threshold: item.try_get::<_, i32>("threshold")? as i64, configuration: item @@ -70,7 +73,7 @@ pub async fn insert_tally_session( election_ids: Vec, area_ids: Vec, tally_session_id: &str, - keys_ceremony_id: &str, + keys_ceremony_id: &Option, execution_status: TallyExecutionStatus, threshold: i32, configuration: Option, @@ -123,7 +126,9 @@ pub async fn insert_tally_session( &election_uuids, &area_uuids, &Uuid::parse_str(tally_session_id)?, - &Uuid::parse_str(keys_ceremony_id)?, + &keys_ceremony_id + .as_ref() + .map(|id| Uuid::parse_str(&id).ok()), &Some(execution_status.to_string()), &threshold, &configuration_json, @@ -499,7 +504,7 @@ struct InsertableTallySession { tenant_id: Uuid, election_event_id: Uuid, id: Uuid, - keys_ceremony_id: Uuid, + keys_ceremony_id: Option, election_ids: Vec, area_ids: Vec, execution_status: Option, @@ -547,7 +552,10 @@ pub async fn insert_many_tally_sessions( tenant_id: Uuid::parse_str(&session.tenant_id)?, election_event_id: Uuid::parse_str(&session.election_event_id)?, id: Uuid::parse_str(&session.id)?, - keys_ceremony_id: Uuid::parse_str(&session.keys_ceremony_id)?, + keys_ceremony_id: session + .keys_ceremony_id + .as_ref() + .and_then(|id| Uuid::parse_str(&id).ok()), election_ids, area_ids, execution_status: session.execution_status.clone(), diff --git a/packages/windmill/src/services/ceremonies/insert_ballots.rs b/packages/windmill/src/services/ceremonies/insert_ballots.rs index d9f248854fe..c12c78062a1 100644 --- a/packages/windmill/src/services/ceremonies/insert_ballots.rs +++ b/packages/windmill/src/services/ceremonies/insert_ballots.rs @@ -11,12 +11,16 @@ use crate::services::database::{get_hasura_pool, get_keycloak_pool, PgConfig}; use crate::services::election::get_election_event_elections; use crate::services::join::merge_join_csv; use crate::services::protocol_manager::*; +use crate::services::public_keys; use crate::services::public_keys::deserialize_public_key; use crate::services::users::list_keycloak_enabled_users_by_area_id_and_authorized_elections; use anyhow::{anyhow, Context, Result}; +use b3::messages::artifact::DkgPublicKey; use b3::messages::message::Message; use b3::messages::newtypes::BatchNumber; +use b3::messages::newtypes::PublicKeyHash; use b3::messages::newtypes::TrusteeSet; +use b3::messages::statement::StatementType; use base64::{ alphabet, engine::{self, general_purpose}, @@ -29,6 +33,7 @@ use sequent_core::ballot::{ ContestEncryptionPolicy, DelegatedVotingPolicy, ElectionPresentation, HashableBallot, }; use sequent_core::multi_ballot::HashableMultiBallot; +use sequent_core::plaintext_ballot::HashablePlaintextBallot; use sequent_core::serialization::base64::{Base64Deserialize, Base64Serialize}; use sequent_core::serialization::deserialize_with_path::{deserialize_str, deserialize_value}; use sequent_core::services::date::ISO8601; @@ -36,9 +41,11 @@ use sequent_core::services::keycloak::get_event_realm; use sequent_core::types::hasura::core::{TallySessionContest, TallySessionContestAnnotations}; use serde_json::json; use std::collections::HashMap; -use strand::backend::ristretto::RistrettoCtx; use strand::elgamal::Ciphertext; +use strand::serialization::StrandDeserialize; +use strand::serialization::StrandSerialize; use strand::signature::StrandSignaturePk; +use strand::{backend::ristretto::RistrettoCtx, context::Ctx}; use tempfile::NamedTempFile; use tokio::task::JoinHandle; use tracing::{event, info, instrument, Level}; @@ -47,6 +54,29 @@ use deadpool_postgres::Client as DbClient; use std::sync::Arc; // Add this import +pub enum BallotContent { + Plaintext(C::P), + Ciphertext(Ciphertext), +} + +impl BallotContent { + /// Tries to convert the ballot into a plaintext. + pub fn try_into_plaintext(self) -> Result { + match self { + BallotContent::Plaintext(p) => Ok(p), + BallotContent::Ciphertext(_) => Err(anyhow!("Expected Plaintext, found Ciphertext")), + } + } + + /// Tries to convert the ballot into a ciphertext. + pub fn try_into_ciphertext(self) -> Result> { + match self { + BallotContent::Ciphertext(c) => Ok(c), + BallotContent::Plaintext(_) => Err(anyhow!("Expected Ciphertext, found Plaintext")), + } + } +} + #[instrument(skip_all, err)] pub async fn insert_ballots_messages( hasura_transaction: &Transaction<'_>, @@ -84,7 +114,7 @@ pub async fn insert_ballots_messages( let realm = get_event_realm(&tenant_id, &election_event_id); // Wrap protocol_manager in an Arc let protocol_manager = Arc::new( - get_protocol_manager( + get_protocol_manager::( hasura_transaction, tenant_id, Some(election_event_id), @@ -95,8 +125,15 @@ pub async fn insert_ballots_messages( let mut board_client = get_b3_pgsql_client().await?; let board_messages = Arc::new(get_board_messages::(board_name, &mut board_client).await?); + let configuration = get_configuration(&board_messages)?; - let public_key_hash = get_public_key_hash::(&board_messages)?; + let public_key_hash = if contest_encryption_policy != ContestEncryptionPolicy::PLAINTEXT { + get_public_key_hash::(&board_messages)? + } else { + // empty public key hash + let pk_h = [0u8; 64]; + PublicKeyHash(strand::util::to_u8_array(&pk_h)?) + }; let selected_trustees: TrusteeSet = generate_trustee_set(&configuration, deserialized_trustee_pks.clone()); @@ -227,22 +264,23 @@ pub async fn insert_ballots_messages( delegate_count_index, )?; - let ciphertexts = ballot_contents + let ballot_contents: Vec> = ballot_contents .into_iter() .map(|ballot_str| { info!("ballot_str: {ballot_str}"); - let ciphertext: Ciphertext = - if ContestEncryptionPolicy::MULTIPLE_CONTESTS - == contest_encryption_policy_clone - { + match contest_encryption_policy_clone { + ContestEncryptionPolicy::MULTIPLE_CONTESTS => { let hashable_multi_ballot: HashableMultiBallot = deserialize_str(&ballot_str)?; let hashable_multi_ballot_contests = hashable_multi_ballot .deserialize_contests() .map_err(|err| anyhow!("{:?}", err))?; - Some(hashable_multi_ballot_contests.ciphertext) - } else { + Ok(BallotContent::Ciphertext( + hashable_multi_ballot_contests.ciphertext, + )) + } + ContestEncryptionPolicy::SINGLE_CONTEST => { let hashable_ballot: HashableBallot = deserialize_str(&ballot_str)?; let contests = hashable_ballot @@ -254,10 +292,29 @@ pub async fn insert_ballots_messages( contest.contest_id == contest_id.clone().unwrap_or_default() }) - .map(|contest| contest.ciphertext.clone()) + .map(|contest| { + BallotContent::Ciphertext(contest.ciphertext.clone()) + }) + .ok_or(anyhow!("Could not get ciphertext")) } - .ok_or(anyhow!("Could not get ciphertext"))?; - Ok(ciphertext) + ContestEncryptionPolicy::PLAINTEXT => { + let hashable_plaintext_ballot: HashablePlaintextBallot = + deserialize_str(&ballot_str)?; + let contests = hashable_plaintext_ballot + .deserialize_contests::() + .map_err(|err| anyhow!("{:?}", err))?; + contests + .iter() + .find(|contest| { + contest.contest_id + == contest_id.clone().unwrap_or_default() + }) + .map(|contest| { + BallotContent::Plaintext(contest.plaintext.clone()) + }) + .ok_or(anyhow!("Could not get ciphertext")) + } + } }) .collect::>>()?; @@ -287,23 +344,49 @@ pub async fn insert_ballots_messages( event!( Level::INFO, "insertable_ballots len: {:?}", - ciphertexts.len() + ballot_contents.len() ); let mut board = get_b3_pgsql_client().await?; let batch = tally_session_contest.session_id.clone() as BatchNumber; - add_ballots_to_board( - &protocol_manager_arc_clone, // Use the Arc clone here - &mut board, - &board_name_clone, - &board_messages_clone, // Use the cloned board_messages - &configuration_clone, - public_key_hash_clone, - selected_trustees_clone, - ciphertexts, - batch, - ) - .await?; + + if contest_encryption_policy_clone == ContestEncryptionPolicy::PLAINTEXT { + let plaintexts = ballot_contents + .into_iter() + .map(BallotContent::try_into_plaintext) + .collect::>>()?; + + add_plaintext_ballots_to_board( + &protocol_manager_arc_clone, + &mut board, + &board_name_clone, + &board_messages_clone, + &configuration_clone, + public_key_hash_clone, + selected_trustees_clone, + plaintexts, + batch, + ) + .await?; + } else { + let ciphertexts = ballot_contents + .into_iter() + .map(BallotContent::try_into_ciphertext) + .collect::>>()?; + + add_ballots_to_board( + &protocol_manager_arc_clone, + &mut board, + &board_name_clone, + &board_messages_clone, + &configuration_clone, + public_key_hash_clone, + selected_trustees_clone, + ciphertexts, + batch, + ) + .await?; + } Ok(updated_tally_session_contest) }) diff --git a/packages/windmill/src/services/ceremonies/tally_ceremony.rs b/packages/windmill/src/services/ceremonies/tally_ceremony.rs index 4fb6a59b6ba..7a0bfe824c8 100644 --- a/packages/windmill/src/services/ceremonies/tally_ceremony.rs +++ b/packages/windmill/src/services/ceremonies/tally_ceremony.rs @@ -131,7 +131,13 @@ pub async fn find_keys_ceremony( tenant_id: &str, election_event_id: &str, elections: &Vec, -) -> Result { + contest_encryption_policy: &ContestEncryptionPolicy, +) -> Result> { + // If plaintext, it's valid to have no keys ceremony. + if *contest_encryption_policy == ContestEncryptionPolicy::PLAINTEXT { + return Ok(None); + } + let keys_ceremonies_set: HashSet = elections .clone() .into_iter() @@ -165,7 +171,7 @@ pub async fn find_keys_ceremony( return Err(anyhow!("Invalid keys ceremony")); } - Ok(keys_ceremony) + Ok(Some(keys_ceremony)) } #[instrument] @@ -237,7 +243,9 @@ pub async fn insert_tally_session_contests( .await?; batch = batch + 1; } - } else if ContestEncryptionPolicy::SINGLE_CONTEST == contest_encryption_policy { + } else if ContestEncryptionPolicy::SINGLE_CONTEST == contest_encryption_policy + || ContestEncryptionPolicy::PLAINTEXT == contest_encryption_policy + { for area_contest in relevant_area_contests { let Some(contest) = contests_map.get(&area_contest.contest_id) else { return Err(anyhow!("Contest not found {:?}", area_contest.contest_id)); @@ -295,7 +303,7 @@ pub async fn create_tally_ceremony( let decoded_ballots_inclusion_policy = election_event.get_decoded_ballots_inclusion_policy(); let delegated_voting_policy = election_event.get_delegated_voting_policy(); let mut final_configuration = configuration.clone().unwrap_or_default(); - final_configuration.contest_encryption_policy = Some(contest_encryption_policy); + final_configuration.contest_encryption_policy = Some(contest_encryption_policy.clone()); final_configuration.decoded_ballots_inclusion_policy = Some(decoded_ballots_inclusion_policy); final_configuration.delegated_voting_policy = Some(delegated_voting_policy); let contests: Vec = all_contests @@ -383,24 +391,61 @@ pub async fn create_tally_ceremony( .map(|val| val.clone()) .collect(); - let keys_ceremony = - find_keys_ceremony(transaction, &tenant_id, &election_event_id, &elections).await?; - let keys_ceremony_status = keys_ceremony.status()?; - let keys_ceremony_id = keys_ceremony.id.clone(); - let initial_status = generate_initial_tally_status(&election_ids, &keys_ceremony_status); - let tally_session_id: String = Uuid::new_v4().to_string(); + // Pass the policy to find_keys_ceremony + let keys_ceremony_opt = find_keys_ceremony( + transaction, + &tenant_id, + &election_event_id, + &elections, + &contest_encryption_policy, + ) + .await?; + let tally_session_id: String = Uuid::new_v4().to_string(); let annotations: Value = json!({ "executer_username": username, "executer_user_id": user_id, }); - let keys_ceremony_policy = keys_ceremony.policy(); + // Handle both encrypted (Some) and plaintext (None) cases + let (keys_ceremony_id_opt, threshold, initial_status, tally_execution_status) = // <-- Renamed + if let Some(keys_ceremony) = keys_ceremony_opt { + // ENCRYPTED: Standard logic + let keys_ceremony_status = keys_ceremony.status()?; + let keys_ceremony_policy = keys_ceremony.policy(); + let tally_execution_status = match keys_ceremony_policy { + CeremoniesPolicy::AUTOMATED_CEREMONIES => TallyExecutionStatus::IN_PROGRESS, + _ => TallyExecutionStatus::STARTED, + }; - let tally_execution_status = match keys_ceremony_policy { - CeremoniesPolicy::AUTOMATED_CEREMONIES => TallyExecutionStatus::IN_PROGRESS, - _ => TallyExecutionStatus::STARTED, - }; + ( + Some(keys_ceremony.id.clone()), // <-- Now Option + keys_ceremony.threshold as i32, + generate_initial_tally_status(&election_ids, &keys_ceremony_status), + tally_execution_status, + ) + } else { + // PLAINTEXT: No keys ceremony, use defaults + ( + None, // <-- Now None + 0, // No threshold + TallyCeremonyStatus { // Create status manually with no trustees + stop_date: None, + logs: generate_tally_initial_log(&election_ids), + trustees: vec![], // No trustees + elections_status: election_ids + .iter() + .map(|election_id| TallyElection { + election_id: election_id.clone(), + status: TallyElectionStatus::WAITING, + progress: 0.0, + }) + .collect(), + }, + TallyExecutionStatus::IN_PROGRESS, // Plaintext can start immediately + ) + }; + // --- END OF MODIFIED LOGIC --- let _tally_session = insert_tally_session( transaction, @@ -409,9 +454,9 @@ pub async fn create_tally_ceremony( election_ids.clone(), area_ids.clone(), &tally_session_id, - &keys_ceremony_id, + &keys_ceremony_id_opt, // <-- Changed to Option<&str> tally_execution_status, - keys_ceremony.threshold as i32, + threshold, Some(final_configuration.clone()), &tally_type, annotations, @@ -533,6 +578,7 @@ pub async fn update_tally_ceremony( if tally_session.threshold > num_connected_trustees as i64 && new_execution_status != TallyExecutionStatus::CANCELLED { + // For plaintext, threshold will be 0, so this check will pass return Err(anyhow!( "Insufficient number of connected trustees {}. Required threshold {}.", num_connected_trustees, @@ -642,11 +688,18 @@ pub async fn set_private_key( } // get the keys ceremonies for this election event + // We must unwrap keys_ceremony_id, which is now Option + let Some(keys_ceremony_id_str) = &tally_session.keys_ceremony_id else { + // A plaintext tally (None) would have no trustees, so it should + // fail the trustee check later. This is a safeguard. + return Err(anyhow!("Tally session has no keys ceremony associated")); + }; + let keys_ceremony = get_keys_ceremony_by_id( transaction, &tenant_id, &election_event_id, - &tally_session.keys_ceremony_id, + keys_ceremony_id_str, // Use the unwrapped &str ) .await?; @@ -659,6 +712,7 @@ pub async fn set_private_key( .find(|trustee| trustee.name == trustee_name); let Some(found_trustee) = found_trustee_opt else { + // This will correctly fail for plaintext tallies as trustees: vec![] return Err(anyhow!( "Trustee not part of the keys ceremony or has invalid state" )); @@ -680,7 +734,6 @@ pub async fn set_private_key( &keys_ceremony, ) .await?; - // FFF tally fix if encrypted_private_key != private_key_base64 { return Ok(false); diff --git a/packages/windmill/src/services/ceremonies/velvet_tally.rs b/packages/windmill/src/services/ceremonies/velvet_tally.rs index 456f3f0f49b..54a678b79e6 100644 --- a/packages/windmill/src/services/ceremonies/velvet_tally.rs +++ b/packages/windmill/src/services/ceremonies/velvet_tally.rs @@ -148,7 +148,9 @@ pub fn prepare_tally_for_area_contest( )); fs::create_dir_all(&ballots_path)?; - if ContestEncryptionPolicy::SINGLE_CONTEST == contest_encryption_policy { + if (ContestEncryptionPolicy::SINGLE_CONTEST == contest_encryption_policy) + || (ContestEncryptionPolicy::PLAINTEXT == contest_encryption_policy) + { let csv_ballots_path = ballots_path.join("ballots.csv"); let mut csv_ballots_file = File::create(&csv_ballots_path)?; let buffer = biguit_ballots.join("\n").into_bytes(); @@ -657,6 +659,7 @@ pub async fn create_config_file( pipe: match contest_encryption_policy { ContestEncryptionPolicy::MULTIPLE_CONTESTS => PipeName::DecodeMCBallots, ContestEncryptionPolicy::SINGLE_CONTEST => PipeName::DecodeBallots, + ContestEncryptionPolicy::PLAINTEXT => PipeName::DecodeBallots, }, config: Some(serde_json::Value::Null), }, diff --git a/packages/windmill/src/services/import/import_election_event.rs b/packages/windmill/src/services/import/import_election_event.rs index 93950239649..04a2db06e7e 100644 --- a/packages/windmill/src/services/import/import_election_event.rs +++ b/packages/windmill/src/services/import/import_election_event.rs @@ -468,7 +468,31 @@ pub async fn get_election_event_schema( tenant_id: String, ) -> Result<(ImportElectionEventSchema, HashMap)> { let original_data: ImportElectionEventSchema = deserialize_str(data_str)?; - replace_ids(data_str, &original_data, id, tenant_id.clone()) + let (schema, ids_map) = replace_ids(data_str, &original_data, id, tenant_id.clone())?; + let final_schema = default_contest_encryption_policy(schema)?; + Ok((final_schema, ids_map)) +} + +fn default_contest_encryption_policy( + mut schema: ImportElectionEventSchema, +) -> Result { + let mut final_schema = schema.clone(); + + let mut election_event_presentation = schema.election_event.presentation.clone().map_or_else( + || Ok(sequent_core::ballot::ElectionEventPresentation::default()), + |value| deserialize_value::(value), + )?; + + election_event_presentation.contest_encryption_policy = Some( + election_event_presentation + .contest_encryption_policy + .clone() + .unwrap_or_default(), + ); + + final_schema.election_event.presentation = + Some(serde_json::to_value(election_event_presentation)?); + Ok(final_schema) } #[instrument(err, skip_all)] diff --git a/packages/windmill/src/services/import/import_tally.rs b/packages/windmill/src/services/import/import_tally.rs index 87e9a503db4..a56ea0434ff 100644 --- a/packages/windmill/src/services/import/import_tally.rs +++ b/packages/windmill/src/services/import/import_tally.rs @@ -364,7 +364,7 @@ pub async fn process_tally_session_record( election_ids, area_ids, is_execution_completed, - keys_ceremony_id, + keys_ceremony_id: Some(keys_ceremony_id), execution_status, threshold, configuration, diff --git a/packages/windmill/src/services/insert_cast_vote.rs b/packages/windmill/src/services/insert_cast_vote.rs index b6542074931..40c7ec71e22 100644 --- a/packages/windmill/src/services/insert_cast_vote.rs +++ b/packages/windmill/src/services/insert_cast_vote.rs @@ -34,12 +34,16 @@ use sequent_core::ballot::{ use sequent_core::ballot::{HashableBallot, HashableBallotContest, SignedHashableBallot}; use sequent_core::encrypt::hash_ballot_sha512; use sequent_core::encrypt::hash_multi_ballot_sha512; +use sequent_core::encrypt::hash_plaintext_ballot; +use sequent_core::encrypt::hash_plaintext_ballot_sha512; use sequent_core::encrypt::DEFAULT_PLAINTEXT_LABEL; use sequent_core::error::BallotError; use sequent_core::multi_ballot::verify_multi_ballot_signature; use sequent_core::multi_ballot::HashableMultiBallot; use sequent_core::multi_ballot::HashableMultiBallotContests; use sequent_core::multi_ballot::SignedHashableMultiBallot; +use sequent_core::plaintext_ballot::verify_plaintext_ballot_signature; +use sequent_core::plaintext_ballot::{HashablePlaintextBallot, SignedHashablePlaintextBallot}; use sequent_core::serialization::deserialize_with_path::*; use sequent_core::services::date::ISO8601; use sequent_core::services::keycloak::get_event_realm; @@ -249,16 +253,25 @@ pub async fn try_insert_cast_vote( .get_presentation() .map_err(|e| CastVoteError::ElectionEventNotFound(e.to_string()))?; - let is_multi_contest = if let Some(presentation) = presentation_opt.clone() { - presentation.contest_encryption_policy == Some(ContestEncryptionPolicy::MULTIPLE_CONTESTS) + let contest_encryption_policy = if let Some(presentation) = presentation_opt.clone() { + presentation.contest_encryption_policy } else { - false + None }; - let hash_result = if is_multi_contest { - deserialize_and_check_multi_ballot(&input, voter_id) - } else { - deserialize_and_check_ballot(&input, voter_id) + let hash_result = match contest_encryption_policy { + Some(ContestEncryptionPolicy::PLAINTEXT) => { + deserialize_and_check_plaintext_ballot(&input, voter_id) + } + Some(ContestEncryptionPolicy::MULTIPLE_CONTESTS) => { + deserialize_and_check_multi_ballot(&input, voter_id) + } + Some(ContestEncryptionPolicy::SINGLE_CONTEST) => { + deserialize_and_check_ballot(&input, voter_id) + } + None => Err(CastVoteError::DeserializeBallotFailed( + "Contest encryption policy not set.".to_string(), + )), }; let (pseudonym_h, vote_h, voter_signature_data) = match hash_result { @@ -606,6 +619,69 @@ pub fn deserialize_and_check_multi_ballot( Ok((pseudonym_h, vote_h, voter_signature_opt)) } +#[instrument(skip(input), err)] +pub fn deserialize_and_check_plaintext_ballot( + input: &InsertCastVoteInput, + voter_id: &str, +) -> Result< + ( + PseudonymHash, + CastVoteHash, + Option<(StrandSignaturePk, StrandSignature)>, + ), + CastVoteError, +> { + let signed_hashable_plaintext_ballot: SignedHashablePlaintextBallot = + deserialize_str(&input.content) + .map_err(|e| CastVoteError::DeserializeBallotFailed(e.to_string()))?; + + let hashable_plaintext_ballot: HashablePlaintextBallot = (&signed_hashable_plaintext_ballot) + .try_into() + .map_err(|e: BallotError| CastVoteError::DeserializeBallotFailed(e.to_string()))?; + + let computed_hash = hash_plaintext_ballot(&hashable_plaintext_ballot) + .map_err(|e| CastVoteError::SerializeBallotFailed(e.to_string()))?; + + /// Verifies that the ballot_id corresponds to the hash of the ballot content + /// The function serves as a security check to ensure that + /// a ballot's content matches its claimed ID. + /// This is crucial for maintaining the integrity of the voting system + /// by preventing ballot tampering or substitution. + if computed_hash != input.ballot_id { + return Err(CastVoteError::BallotIdMismatch(format!( + "Expected {} but got {}", + computed_hash, input.ballot_id + ))); + } + + let pseudonym_hash_bytes = hash_voter_id(voter_id) + .map_err(|e| CastVoteError::SerializeVoterIdFailed(e.to_string()))?; + + let vote_hash_bytes = hash_plaintext_ballot_sha512(&hashable_plaintext_ballot) + .map_err(|e| CastVoteError::SerializeBallotFailed(e.to_string()))?; + + let pseudonym_h = PseudonymHash(HashWrapper::new(pseudonym_hash_bytes)); + let vote_h = CastVoteHash(HashWrapper::new(vote_hash_bytes)); + + let hashable_plaintext_ballot_contests = hashable_plaintext_ballot + .deserialize_contests::() + .map_err(|e| CastVoteError::DeserializeContestsFailed(e.to_string()))?; + + // Check ballot signature + let election_id = input.election_id.to_string(); + let voter_signature_opt = verify_plaintext_ballot_signature( + &input.ballot_id, + &election_id, + &signed_hashable_plaintext_ballot, + ) + .map_err(|err| { + CastVoteError::BallotVoterSignatureFailed(format!("Ballot signature check failed: {err}")) + })?; + info!("is_signature_verified = {}", voter_signature_opt.is_some()); + + Ok((pseudonym_h, vote_h, voter_signature_opt)) +} + #[instrument( skip( input, diff --git a/packages/windmill/src/services/protocol_manager.rs b/packages/windmill/src/services/protocol_manager.rs index 31441ba1ba9..25553c591c3 100644 --- a/packages/windmill/src/services/protocol_manager.rs +++ b/packages/windmill/src/services/protocol_manager.rs @@ -7,6 +7,8 @@ use b3::messages::artifact::Shares; use b3::messages::artifact::{Ballots, Channel, Configuration, DkgPublicKey, TrusteeShareData}; use b3::messages::message::Message; use b3::messages::newtypes::BatchNumber; +use b3::messages::newtypes::CiphertextsHash; +use b3::messages::newtypes::DecryptionFactorsHashes; use b3::messages::newtypes::PublicKeyHash; use b3::messages::newtypes::{TrusteeSet, MAX_TRUSTEES, NULL_TRUSTEE}; use b3::messages::protocol_manager::{ProtocolManager, ProtocolManagerConfig}; @@ -15,6 +17,7 @@ use deadpool_postgres::Transaction; use strand::backend::ristretto::RistrettoCtx; use strand::context::Ctx; use strand::elgamal::Ciphertext; +use strand::hash::STRAND_HASH_LENGTH_BYTES; use strand::serialization::StrandDeserialize; use strand::serialization::StrandSerialize; use strand::util::StrandError; @@ -24,12 +27,16 @@ use std::env; use std::marker::PhantomData; use tracing::{event, info, instrument, Level}; +use crate::services::ceremonies::insert_ballots::BallotContent; use crate::services::vault; use b3::client::pgsql::B3MessageRow; use electoral_log::BoardClient; use immudb_rs::{sql_value::Value, Client, NamedParam, SqlValue}; use strand::signature::{StrandSignaturePk, StrandSignatureSk}; +use b3::messages::artifact::Plaintexts; +use strand::serialization::StrandVector; + pub fn get_protocol_manager_secret_path(board_name: &str) -> String { format!("boards/{board_name}/protocol-manager") } @@ -413,6 +420,63 @@ pub async fn add_ballots_to_board( b3_client.insert_ballots::(board_name, message).await } +#[instrument( + skip( + messages, + configuration, + public_key_hash, + selected_trustees, + ballots, + b3_client + ), + err +)] +pub async fn add_plaintext_ballots_to_board( + pm: &ProtocolManager, + b3_client: &mut PgsqlB3Client, + board_name: &str, + messages: &Vec, + configuration: &Configuration, + public_key_hash: PublicKeyHash, + selected_trustees: TrusteeSet, + ballots: Vec, + batch: BatchNumber, +) -> Result<()> { + let existing_message = messages.iter().find(|message| { + let batch_number = message.statement.get_batch_number(); + let kind = message.statement.get_kind(); + batch_number == batch && StatementType::Ballots == kind + }); + if let Some(_message) = existing_message { + event!( + Level::INFO, + "Not adding Ballot to board {} as it already exists for batch {}", + board_name, + batch + ); + return Ok(()); + } + + let ballots_len = ballots.len(); + let dfactor_hs = DecryptionFactorsHashes([[0u8; STRAND_HASH_LENGTH_BYTES]; MAX_TRUSTEES]); + let cipher_h = CiphertextsHash([0u8; STRAND_HASH_LENGTH_BYTES]); + + let message = Message::plaintexts_msg::>( + configuration, + batch, + Plaintexts(StrandVector(ballots)), + dfactor_hs, + cipher_h, + public_key_hash, + pm, + )?; + info!( + "Adding configuration to the board for batch {} and number of ballots {}", + batch, ballots_len + ); + b3_client.insert_ballots::(board_name, message).await +} + #[instrument(err)] pub async fn get_board_client() -> Result { let username = env::var("IMMUDB_USER").context("IMMUDB_USER must be set")?; diff --git a/packages/windmill/src/tasks/execute_tally_session.rs b/packages/windmill/src/tasks/execute_tally_session.rs index d203b7ecc59..724bfa5536a 100644 --- a/packages/windmill/src/tasks/execute_tally_session.rs +++ b/packages/windmill/src/tasks/execute_tally_session.rs @@ -33,9 +33,11 @@ use crate::services::ceremonies::velvet_tally::run_velvet_tally; use crate::services::ceremonies::velvet_tally::AreaContestDataType; use crate::services::database::{get_hasura_pool, get_keycloak_pool}; use crate::services::election::get_election_event_elections; +use crate::services::election_event_board::get_election_event_board; use crate::services::election_event_status::get_election_event_status; use crate::services::pg_lock::PgLock; use crate::services::protocol_manager; +use crate::services::public_keys; use crate::services::reports::electoral_results::ElectoralResults; use crate::services::reports::initialization::InitializationTemplate; use crate::services::reports::template_renderer::{ @@ -49,6 +51,7 @@ use crate::services::temp_path::{ }; use crate::services::users::list_users; use crate::services::users::ListUsersFilter; +use crate::tasks::execute_tally_session::protocol_manager::check_configuration_exists; use crate::types::error::{Error, Result}; use anyhow::{anyhow, Context, Result as AnyhowResult}; use b3::messages::{artifact::Plaintexts, message::Message, statement::StatementType}; @@ -87,7 +90,12 @@ use sequent_core::types::templates::SendTemplateBody; use std::collections::HashMap; use std::collections::HashSet; use std::str::FromStr; -use strand::{backend::ristretto::RistrettoCtx, context::Ctx, serialization::StrandDeserialize}; +use strand::{ + backend::ristretto::RistrettoCtx, + context::Ctx, + serialization::StrandDeserialize, + signature::{StrandSignaturePk, StrandSignatureSk}, +}; use tempfile::tempdir; use tokio::time::Duration as ChronoDuration; use tracing::{event, info, instrument, warn, Level}; @@ -378,6 +386,12 @@ async fn process_plaintexts( &tally_session_contest, areas, )?, + ContestEncryptionPolicy::PLAINTEXT => generate_area_contests( + &relevant_plaintexts, + &ballot_styles, + &tally_session_contest, + areas, + )?, }; event!(Level::WARN, "Num almost_vec = {}", almost_vec.len()); let treenode_areas: Vec = areas.iter().map(|area| area.into()).collect(); @@ -520,14 +534,26 @@ pub async fn upsert_ballots_messages( .into_iter() .map(|tally_session_contest| tally_session_contest.session_id.clone() as i64) .collect(); - let existing_ballots_batches: Vec = messages - .iter() - .filter(|message| { - expected_batch_ids.contains(&(message.statement.get_batch_number() as i64)) - && StatementType::Ballots == message.statement.get_kind() - }) - .map(|message| message.statement.get_batch_number() as i64) - .collect(); + let existing_ballots_batches: Vec = + if contest_encryption_policy == ContestEncryptionPolicy::PLAINTEXT { + messages + .iter() + .filter(|message| { + expected_batch_ids.contains(&(message.statement.get_batch_number() as i64)) + && StatementType::Plaintexts == message.statement.get_kind() + }) + .map(|message| message.statement.get_batch_number() as i64) + .collect() + } else { + messages + .iter() + .filter(|message| { + expected_batch_ids.contains(&(message.statement.get_batch_number() as i64)) + && StatementType::Ballots == message.statement.get_kind() + }) + .map(|message| message.statement.get_batch_number() as i64) + .collect() + }; event!( Level::INFO, "existing_ballots_batches: '{:?}'", @@ -632,7 +658,7 @@ async fn map_plaintext_data( election_event_id: String, tally_session_id: String, ceremony_status: TallyCeremonyStatus, - keys_ceremony: &KeysCeremony, + keys_ceremony: &Option, tally_session: TallySession, tally_session_execution: TallySessionExecution, tally_session_contest: Vec, @@ -664,13 +690,21 @@ async fn map_plaintext_data( }; // get name of bulletin board - let (bulletin_board, _) = get_keys_ceremony_board( - hasura_transaction, - &tenant_id, - &election_event_id, - keys_ceremony, - ) - .await?; + let bulletin_board = if let Some(keys_ceremony) = keys_ceremony { + let (bulletin_board, _) = get_keys_ceremony_board( + hasura_transaction, + &tenant_id, + &election_event_id, + keys_ceremony, + ) + .await?; + + bulletin_board + } else { + // get board name + get_election_event_board(election_event.bulletin_board_reference.clone()) + .with_context(|| "missing bulletin board")? + }; // let tally_session = &tally_session_data.sequent_backend_tally_session[0]; let tally_session_created_at_timestamp_secs = @@ -690,7 +724,9 @@ async fn map_plaintext_data( .await .with_context(|| "error listing existing keys ceremonies")?; - if keys_ceremonies.is_empty() { + if keys_ceremonies.is_empty() + && election_event.get_contest_encryption_policy() != ContestEncryptionPolicy::PLAINTEXT + { event!( Level::INFO, "Election Event {} has no keys ceremony", @@ -699,44 +735,50 @@ async fn map_plaintext_data( return Ok(None); } - let keys_ceremony_policy = keys_ceremony.policy(); - - let threshold = keys_ceremonies[0].threshold as usize; - let mut available_trustees: Vec = match keys_ceremony_policy { - CeremoniesPolicy::MANUAL_CEREMONIES => ceremony_status - .trustees - .into_iter() - .filter(|trustee| TallyTrusteeStatus::KEY_RESTORED == trustee.status) - .map(|trustee| trustee.name.clone()) - .collect(), - CeremoniesPolicy::AUTOMATED_CEREMONIES => ceremony_status - .trustees - .into_iter() - .map(|trustee| trustee.name.clone()) - .collect(), - }; + let trustee_names = if let Some(keys_ceremony) = keys_ceremony { + let keys_ceremony_policy = keys_ceremony.policy(); + + let threshold = keys_ceremonies[0].threshold as usize; + let mut available_trustees: Vec = match keys_ceremony_policy { + CeremoniesPolicy::MANUAL_CEREMONIES => ceremony_status + .trustees + .into_iter() + .filter(|trustee| TallyTrusteeStatus::KEY_RESTORED == trustee.status) + .map(|trustee| trustee.name.clone()) + .collect(), + CeremoniesPolicy::AUTOMATED_CEREMONIES => ceremony_status + .trustees + .into_iter() + .map(|trustee| trustee.name.clone()) + .collect(), + }; - let mut rng = StdRng::from_os_rng(); - available_trustees.shuffle(&mut rng); + let mut rng = StdRng::from_os_rng(); + available_trustees.shuffle(&mut rng); - let trustee_names: Vec = available_trustees.into_iter().take(threshold).collect(); + let trustee_names: Vec = available_trustees.into_iter().take(threshold).collect(); - if trustee_names.len() < threshold { + if trustee_names.len() < threshold { + event!( + Level::INFO, + "Election Event {} has {} connected trustees but threshold is {}", + election_event_id.clone(), + trustee_names.len(), + threshold + ); + return Ok(None); + } event!( Level::INFO, - "Election Event {} has {} connected trustees but threshold is {}", + "Election Event {}. Selected trustees {:#?}", election_event_id.clone(), - trustee_names.len(), - threshold + trustee_names ); - return Ok(None); - } - event!( - Level::INFO, - "Election Event {}. Selected trustees {:#?}", - election_event_id.clone(), + trustee_names - ); + } else { + Vec::new() + }; if execution_status != TallyExecutionStatus::IN_PROGRESS { event!( @@ -761,6 +803,48 @@ async fn map_plaintext_data( let messages: Vec = protocol_manager::convert_board_messages(&board_messages)?; print_messages(&messages, &bulletin_board)?; + // Since we skipped key ceremony for plaintext we generate the configuration using dummy trustees. + let contest_encryption_policy = tally_session + .configuration + .clone() + .unwrap_or_default() + .get_contest_encryption_policy(); + + if contest_encryption_policy == ContestEncryptionPolicy::PLAINTEXT { + let configuration_exists = check_configuration_exists(&bulletin_board).await?; + + if !configuration_exists { + let dummy_trustees_count = 2; + let dummy_keys: Vec = (0..dummy_trustees_count) + .map(|_| { + // Generate a random secret key + let sk = StrandSignatureSk::gen() + .map_err(|e| anyhow!("Failed to gen dummy key: {:?}", e))?; + // Derive the public key + let pk = StrandSignaturePk::from_sk(&sk) + .map_err(|e| anyhow!("Failed to derive dummy pk: {:?}", e))?; + + // Serialize to the format create_keys expects (Base64 DER) + // Note: Verify the exact method name in your version of strand (e.g. to_string, to_b64, or to_der_b64_string) + pk.to_der_b64_string() + .map_err(|e| anyhow!("Failed to serialize dummy pk: {:?}", e)) + }) + .collect::>>()?; + + public_keys::create_keys( + &hasura_transaction, + &tenant_id, + &election_event_id, + &bulletin_board, + dummy_keys, + dummy_trustees_count, + ) + .await?; + + return Ok(None); + } + }; + let new_ballots_messages = upsert_ballots_messages( hasura_transaction, keycloak_transaction, @@ -1032,13 +1116,22 @@ pub async fn execute_tally_session_wrapped( return Ok(()); }; - let keys_ceremony = get_keys_ceremony_by_id( - hasura_transaction, - &tenant_id, - &election_event_id, - &tally_session.keys_ceremony_id, - ) - .await?; + let keys_ceremony = if let Some(ref keys_ceremony_id) = tally_session.keys_ceremony_id { + Some( + get_keys_ceremony_by_id( + hasura_transaction, + &tenant_id, + &election_event_id, + &tally_session + .keys_ceremony_id + .clone() + .ok_or_else(|| anyhow!("Tally session has no id"))?, + ) + .await?, + ) + } else { + None + }; let tally_type_enum = tally_type .map(|val: String| TallyType::try_from(val.as_str()).unwrap_or_default()) diff --git a/packages/windmill/src/tasks/generate_template.rs b/packages/windmill/src/tasks/generate_template.rs index be78537af82..ae69e4e8401 100644 --- a/packages/windmill/src/tasks/generate_template.rs +++ b/packages/windmill/src/tasks/generate_template.rs @@ -93,6 +93,7 @@ async fn create_config( pipe: match contest_encryption_policy { ContestEncryptionPolicy::MULTIPLE_CONTESTS => PipeName::MCBallotImages, ContestEncryptionPolicy::SINGLE_CONTEST => PipeName::BallotImages, + ContestEncryptionPolicy::PLAINTEXT => PipeName::BallotImages, }, config: Some(pipe_config), }], diff --git a/packages/windmill/src/tasks/insert_election_event.rs b/packages/windmill/src/tasks/insert_election_event.rs index fd38645b668..e45a0aacbf4 100644 --- a/packages/windmill/src/tasks/insert_election_event.rs +++ b/packages/windmill/src/tasks/insert_election_event.rs @@ -15,6 +15,7 @@ use celery::error::TaskError; use deadpool_postgres::Transaction; use keycloak::types::RealmRepresentation; use sequent_core; +use sequent_core::serialization::deserialize_with_path::deserialize_value; use sequent_core::services::connection; use sequent_core::services::keycloak::get_event_realm; use sequent_core::services::keycloak::{get_client_credentials, KeycloakAdminClient}; @@ -57,6 +58,18 @@ pub async fn insert_election_event_anyhow( final_object.voting_channels = serde_json::to_value(VotingChannels::default()).ok(); } + let mut election_event_presentation = final_object.presentation.clone().map_or_else( + || Ok(sequent_core::ballot::ElectionEventPresentation::default()), + |value| deserialize_value::(value), + )?; + election_event_presentation.contest_encryption_policy = Some( + election_event_presentation + .contest_encryption_policy + .clone() + .unwrap_or_default(), + ); + final_object.presentation = Some(serde_json::to_value(election_event_presentation)?); + match upsert_keycloak_realm(tenant_id.as_str(), &id.as_ref(), None).await { Ok(realm) => Some(realm), Err(err) => { diff --git a/packages/yarn.lock b/packages/yarn.lock index 2a1e271ec63..f0158c6112c 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -17242,16 +17242,16 @@ sentence-case@^3.0.4: "sequent-core@file:./admin-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" uid "e953be0882f18ddc2b03c22cc70df098edda1dda" - resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#e953be0882f18ddc2b03c22cc70df098edda1dda" + resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#701dd41e4175bcb4c06ce99e790411f667cc3b76" "sequent-core@file:./ballot-verifier/rust/sequent-core-0.1.0.tgz": version "0.1.0" uid "e953be0882f18ddc2b03c22cc70df098edda1dda" - resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#e953be0882f18ddc2b03c22cc70df098edda1dda" + resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#701dd41e4175bcb4c06ce99e790411f667cc3b76" "sequent-core@file:./voting-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#e953be0882f18ddc2b03c22cc70df098edda1dda" + resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#701dd41e4175bcb4c06ce99e790411f667cc3b76" serialize-javascript@6.0.0, serialize-javascript@^6.0.0, serialize-javascript@^6.0.2: version "6.0.2"