@sip-protocol/sdk 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/dist/browser.d.mts +4 -4
- package/dist/browser.d.ts +4 -4
- package/dist/browser.js +2752 -448
- package/dist/browser.mjs +31 -1
- package/dist/chunk-7QZPORY5.mjs +15604 -0
- package/dist/chunk-C2NPCUAJ.mjs +17010 -0
- package/dist/chunk-FCVLFUIC.mjs +16699 -0
- package/dist/chunk-G5UHXECN.mjs +16340 -0
- package/dist/chunk-GEDEIZHJ.mjs +16798 -0
- package/dist/chunk-GOOEOAMV.mjs +17026 -0
- package/dist/chunk-MTNYSNR7.mjs +16269 -0
- package/dist/chunk-O5PIB2EA.mjs +16698 -0
- package/dist/chunk-PCFM7FQO.mjs +17010 -0
- package/dist/chunk-QK464ARC.mjs +16946 -0
- package/dist/chunk-VNBMNGC3.mjs +16698 -0
- package/dist/chunk-W5TUELDQ.mjs +16947 -0
- package/dist/index-CD_zShu-.d.ts +10870 -0
- package/dist/index-CQBYdLYy.d.mts +10976 -0
- package/dist/index-Cg9TYEPv.d.mts +11321 -0
- package/dist/index-CqZJOO8C.d.mts +11323 -0
- package/dist/index-CywN9Bnp.d.ts +11321 -0
- package/dist/index-DHy5ZjCD.d.ts +10976 -0
- package/dist/index-DfsVsmxu.d.ts +11323 -0
- package/dist/index-ObjwyVDX.d.mts +10870 -0
- package/dist/index-m0xbSfmT.d.mts +11318 -0
- package/dist/index-rWLEgvhN.d.ts +11318 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2737 -427
- package/dist/index.mjs +31 -1
- package/dist/noir-DKfEzWy9.d.mts +482 -0
- package/dist/noir-DKfEzWy9.d.ts +482 -0
- package/dist/proofs/noir.d.mts +1 -1
- package/dist/proofs/noir.d.ts +1 -1
- package/dist/proofs/noir.js +12 -3
- package/dist/proofs/noir.mjs +12 -3
- package/package.json +16 -14
- package/src/adapters/near-intents.ts +13 -3
- package/src/auction/index.ts +20 -0
- package/src/auction/sealed-bid.ts +1037 -0
- package/src/compliance/derivation.ts +13 -3
- package/src/compliance/reports.ts +5 -4
- package/src/cosmos/ibc-stealth.ts +2 -2
- package/src/cosmos/stealth.ts +2 -2
- package/src/governance/index.ts +19 -0
- package/src/governance/private-vote.ts +1116 -0
- package/src/index.ts +50 -2
- package/src/intent.ts +145 -8
- package/src/nft/index.ts +27 -0
- package/src/nft/private-nft.ts +811 -0
- package/src/proofs/browser-utils.ts +1 -7
- package/src/proofs/noir.ts +34 -7
- package/src/settlement/backends/direct-chain.ts +14 -3
- package/src/stealth.ts +31 -13
- package/src/types/browser.d.ts +67 -0
- package/src/validation.ts +4 -2
- package/src/wallet/bitcoin/adapter.ts +159 -15
- package/src/wallet/bitcoin/types.ts +340 -15
- package/src/wallet/cosmos/mock.ts +16 -12
- package/src/wallet/hardware/ledger.ts +82 -12
- package/src/wallet/hardware/types.ts +2 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private Voting for SIP Protocol
|
|
3
|
+
*
|
|
4
|
+
* Provides encrypted voting with timelock or committee key encryption.
|
|
5
|
+
* Enables private DAO governance where votes are hidden during voting period
|
|
6
|
+
* and revealed after deadline or by committee decision.
|
|
7
|
+
*
|
|
8
|
+
* ## Security Properties
|
|
9
|
+
* - **Confidentiality**: Votes encrypted until revelation
|
|
10
|
+
* - **Integrity**: Authentication tag prevents tampering
|
|
11
|
+
* - **Verifiability**: Revealed votes can be verified against encrypted versions
|
|
12
|
+
*
|
|
13
|
+
* ## Use Cases
|
|
14
|
+
* - Private DAO governance proposals
|
|
15
|
+
* - Confidential treasury spending votes
|
|
16
|
+
* - Timelock-based vote revelation
|
|
17
|
+
* - Committee-controlled vote disclosure
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { HexString } from '@sip-protocol/types'
|
|
21
|
+
import { sha256 } from '@noble/hashes/sha256'
|
|
22
|
+
import { hkdf } from '@noble/hashes/hkdf'
|
|
23
|
+
import { bytesToHex, hexToBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
|
|
24
|
+
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
|
|
25
|
+
import { ValidationError, CryptoError, ErrorCode } from '../errors'
|
|
26
|
+
import { secureWipe } from '../secure-memory'
|
|
27
|
+
import { isValidHex } from '../validation'
|
|
28
|
+
import { commit, addCommitments, generateBlinding } from '../commitment'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Domain separation for vote encryption key derivation
|
|
32
|
+
*/
|
|
33
|
+
const VOTE_ENCRYPTION_DOMAIN = 'SIP-PRIVATE-VOTE-ENCRYPTION-V1'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* XChaCha20-Poly1305 nonce size (24 bytes)
|
|
37
|
+
*/
|
|
38
|
+
const NONCE_SIZE = 24
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maximum size for vote data (1MB)
|
|
42
|
+
* Prevents DoS attacks via large payloads
|
|
43
|
+
*/
|
|
44
|
+
const MAX_VOTE_DATA_SIZE = 1024 * 1024
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encrypted vote data
|
|
48
|
+
*/
|
|
49
|
+
export interface EncryptedVote {
|
|
50
|
+
/** The encrypted vote data (includes authentication tag) */
|
|
51
|
+
ciphertext: HexString
|
|
52
|
+
/** Nonce used for encryption (needed for decryption) */
|
|
53
|
+
nonce: HexString
|
|
54
|
+
/** Hash of the encryption key used (for key identification) */
|
|
55
|
+
encryptionKeyHash: HexString
|
|
56
|
+
/** Proposal ID this vote is for */
|
|
57
|
+
proposalId: string
|
|
58
|
+
/** Voter's identifier (public key or address) */
|
|
59
|
+
voter: string
|
|
60
|
+
/** Timestamp when vote was cast */
|
|
61
|
+
timestamp: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Revealed vote data
|
|
66
|
+
*/
|
|
67
|
+
export interface RevealedVote {
|
|
68
|
+
/** Proposal ID */
|
|
69
|
+
proposalId: string
|
|
70
|
+
/** Vote choice (e.g., 0 = no, 1 = yes, 2 = abstain) */
|
|
71
|
+
choice: number
|
|
72
|
+
/** Voting weight/power */
|
|
73
|
+
weight: bigint
|
|
74
|
+
/** Voter's identifier */
|
|
75
|
+
voter: string
|
|
76
|
+
/** Timestamp when vote was cast */
|
|
77
|
+
timestamp: number
|
|
78
|
+
/** The encrypted vote this was revealed from */
|
|
79
|
+
encryptedVote: EncryptedVote
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Encrypted tally of aggregated votes
|
|
84
|
+
*
|
|
85
|
+
* Uses Pedersen commitment homomorphism to sum votes without decrypting
|
|
86
|
+
* individual votes. Each choice gets a separate commitment.
|
|
87
|
+
*/
|
|
88
|
+
export interface EncryptedTally {
|
|
89
|
+
/** Proposal ID this tally is for */
|
|
90
|
+
proposalId: string
|
|
91
|
+
/** Map of choice -> aggregated commitment (choice as string key) */
|
|
92
|
+
tallies: Record<string, HexString>
|
|
93
|
+
/**
|
|
94
|
+
* Encrypted blinding factors for each choice (committee holds decryption key)
|
|
95
|
+
* This is encrypted with XChaCha20-Poly1305 using committee key
|
|
96
|
+
*/
|
|
97
|
+
encryptedBlindings: Record<string, { ciphertext: HexString; nonce: HexString }>
|
|
98
|
+
/** Number of votes included in this tally */
|
|
99
|
+
voteCount: number
|
|
100
|
+
/** Timestamp when tally was computed */
|
|
101
|
+
timestamp: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Final revealed tally results
|
|
106
|
+
*/
|
|
107
|
+
export interface TallyResult {
|
|
108
|
+
/** Proposal ID */
|
|
109
|
+
proposalId: string
|
|
110
|
+
/** Map of choice -> total weight (choice as string key) */
|
|
111
|
+
results: Record<string, bigint>
|
|
112
|
+
/** Total number of votes tallied */
|
|
113
|
+
voteCount: number
|
|
114
|
+
/** Timestamp when tally was revealed */
|
|
115
|
+
timestamp: number
|
|
116
|
+
/** The encrypted tally this was revealed from */
|
|
117
|
+
encryptedTally: EncryptedTally
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decryption share from a committee member
|
|
122
|
+
*
|
|
123
|
+
* In threshold cryptography, each committee member contributes a share
|
|
124
|
+
* to collectively decrypt the tally.
|
|
125
|
+
*/
|
|
126
|
+
export interface DecryptionShare {
|
|
127
|
+
/** Committee member's identifier */
|
|
128
|
+
memberId: string
|
|
129
|
+
/** The decryption share data */
|
|
130
|
+
share: HexString
|
|
131
|
+
/** Signature or proof that this share is valid (future use) */
|
|
132
|
+
proof?: HexString
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parameters for casting a vote
|
|
137
|
+
*/
|
|
138
|
+
export interface CastVoteParams {
|
|
139
|
+
/** Proposal ID to vote on */
|
|
140
|
+
proposalId: string
|
|
141
|
+
/** Vote choice (e.g., 0 = no, 1 = yes, 2 = abstain) */
|
|
142
|
+
choice: number
|
|
143
|
+
/** Voting weight/power */
|
|
144
|
+
weight: bigint
|
|
145
|
+
/** Encryption key (timelock or committee key) */
|
|
146
|
+
encryptionKey: string
|
|
147
|
+
/** Optional voter identifier (defaults to 'anonymous') */
|
|
148
|
+
voter?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Vote data that gets encrypted
|
|
153
|
+
*/
|
|
154
|
+
interface VoteData {
|
|
155
|
+
proposalId: string
|
|
156
|
+
choice: number
|
|
157
|
+
weight: string // bigint serialized as string
|
|
158
|
+
voter: string
|
|
159
|
+
timestamp: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Private voting implementation with encryption
|
|
164
|
+
*
|
|
165
|
+
* Enables confidential voting where votes are encrypted during the voting period
|
|
166
|
+
* and can be revealed later with the appropriate decryption key.
|
|
167
|
+
*
|
|
168
|
+
* @example Cast encrypted vote
|
|
169
|
+
* ```typescript
|
|
170
|
+
* const voting = new PrivateVoting()
|
|
171
|
+
*
|
|
172
|
+
* // Committee derives a shared encryption key
|
|
173
|
+
* const encryptionKey = deriveCommitteeKey(...)
|
|
174
|
+
*
|
|
175
|
+
* const encryptedVote = voting.castVote({
|
|
176
|
+
* proposalId: 'proposal-123',
|
|
177
|
+
* choice: 1, // yes
|
|
178
|
+
* weight: 1000n,
|
|
179
|
+
* encryptionKey,
|
|
180
|
+
* })
|
|
181
|
+
*
|
|
182
|
+
* // Store encrypted vote on-chain or in database
|
|
183
|
+
* await storeVote(encryptedVote)
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* @example Reveal votes after deadline
|
|
187
|
+
* ```typescript
|
|
188
|
+
* // After voting period ends, reveal votes
|
|
189
|
+
* const decryptionKey = unlockTimelockKey(...)
|
|
190
|
+
*
|
|
191
|
+
* const revealed = voting.revealVote(
|
|
192
|
+
* encryptedVote,
|
|
193
|
+
* decryptionKey
|
|
194
|
+
* )
|
|
195
|
+
*
|
|
196
|
+
* console.log(`Vote for proposal ${revealed.proposalId}:`)
|
|
197
|
+
* console.log(` Choice: ${revealed.choice}`)
|
|
198
|
+
* console.log(` Weight: ${revealed.weight}`)
|
|
199
|
+
* ```
|
|
200
|
+
*
|
|
201
|
+
* @example Committee-controlled revelation
|
|
202
|
+
* ```typescript
|
|
203
|
+
* // Committee member reveals vote when authorized
|
|
204
|
+
* const committeeKey = getCommitteeMemberKey(memberId)
|
|
205
|
+
*
|
|
206
|
+
* try {
|
|
207
|
+
* const revealed = voting.revealVote(encryptedVote, committeeKey)
|
|
208
|
+
* // Process revealed vote
|
|
209
|
+
* } catch (e) {
|
|
210
|
+
* console.error('Unauthorized revelation attempt')
|
|
211
|
+
* }
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
export class PrivateVoting {
|
|
215
|
+
/**
|
|
216
|
+
* Cast an encrypted vote
|
|
217
|
+
*
|
|
218
|
+
* Encrypts vote data using XChaCha20-Poly1305 authenticated encryption.
|
|
219
|
+
* The encryption key is typically derived from:
|
|
220
|
+
* - Timelock encryption (reveals after specific time)
|
|
221
|
+
* - Committee multisig key (reveals by committee decision)
|
|
222
|
+
* - Threshold scheme (reveals when threshold reached)
|
|
223
|
+
*
|
|
224
|
+
* @param params - Vote casting parameters
|
|
225
|
+
* @returns Encrypted vote that can be stored publicly
|
|
226
|
+
*
|
|
227
|
+
* @throws {ValidationError} If parameters are invalid
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```typescript
|
|
231
|
+
* const voting = new PrivateVoting()
|
|
232
|
+
*
|
|
233
|
+
* const encryptedVote = voting.castVote({
|
|
234
|
+
* proposalId: 'prop-001',
|
|
235
|
+
* choice: 1,
|
|
236
|
+
* weight: 100n,
|
|
237
|
+
* encryptionKey: '0xabc...',
|
|
238
|
+
* })
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
castVote(params: CastVoteParams): EncryptedVote {
|
|
242
|
+
// Validate parameters
|
|
243
|
+
this.validateCastVoteParams(params)
|
|
244
|
+
|
|
245
|
+
const { proposalId, choice, weight, encryptionKey, voter = 'anonymous' } = params
|
|
246
|
+
|
|
247
|
+
// Derive encryption key from provided key
|
|
248
|
+
const derivedKey = this.deriveEncryptionKey(encryptionKey, proposalId)
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
// Generate random nonce (24 bytes for XChaCha20)
|
|
252
|
+
const nonce = randomBytes(NONCE_SIZE)
|
|
253
|
+
|
|
254
|
+
// Prepare vote data
|
|
255
|
+
const voteData: VoteData = {
|
|
256
|
+
proposalId,
|
|
257
|
+
choice,
|
|
258
|
+
weight: weight.toString(),
|
|
259
|
+
voter,
|
|
260
|
+
timestamp: Date.now(),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Serialize to JSON
|
|
264
|
+
const plaintext = utf8ToBytes(JSON.stringify(voteData))
|
|
265
|
+
|
|
266
|
+
// Encrypt with XChaCha20-Poly1305
|
|
267
|
+
const cipher = xchacha20poly1305(derivedKey, nonce)
|
|
268
|
+
const ciphertext = cipher.encrypt(plaintext)
|
|
269
|
+
|
|
270
|
+
// Compute encryption key hash for identification
|
|
271
|
+
const keyHash = sha256(hexToBytes(encryptionKey.slice(2)))
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ciphertext: `0x${bytesToHex(ciphertext)}` as HexString,
|
|
275
|
+
nonce: `0x${bytesToHex(nonce)}` as HexString,
|
|
276
|
+
encryptionKeyHash: `0x${bytesToHex(keyHash)}` as HexString,
|
|
277
|
+
proposalId,
|
|
278
|
+
voter,
|
|
279
|
+
timestamp: voteData.timestamp,
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
// Securely wipe derived key after use
|
|
283
|
+
secureWipe(derivedKey)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reveal an encrypted vote
|
|
289
|
+
*
|
|
290
|
+
* Decrypts vote data using the provided decryption key. The key must match
|
|
291
|
+
* the original encryption key used when casting the vote.
|
|
292
|
+
*
|
|
293
|
+
* @param vote - Encrypted vote to reveal
|
|
294
|
+
* @param decryptionKey - Key to decrypt the vote (must match encryption key)
|
|
295
|
+
* @returns Revealed vote data
|
|
296
|
+
*
|
|
297
|
+
* @throws {CryptoError} If decryption fails (wrong key or tampered data)
|
|
298
|
+
* @throws {ValidationError} If vote data is invalid
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* ```typescript
|
|
302
|
+
* const voting = new PrivateVoting()
|
|
303
|
+
*
|
|
304
|
+
* try {
|
|
305
|
+
* const revealed = voting.revealVote(encryptedVote, decryptionKey)
|
|
306
|
+
* console.log(`Choice: ${revealed.choice}, Weight: ${revealed.weight}`)
|
|
307
|
+
* } catch (e) {
|
|
308
|
+
* console.error('Failed to reveal vote:', e.message)
|
|
309
|
+
* }
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
revealVote(
|
|
313
|
+
vote: EncryptedVote,
|
|
314
|
+
decryptionKey: string,
|
|
315
|
+
): RevealedVote {
|
|
316
|
+
// Validate encrypted vote
|
|
317
|
+
this.validateEncryptedVote(vote)
|
|
318
|
+
|
|
319
|
+
// Validate decryption key
|
|
320
|
+
if (!isValidHex(decryptionKey)) {
|
|
321
|
+
throw new ValidationError(
|
|
322
|
+
'decryptionKey must be a valid hex string with 0x prefix',
|
|
323
|
+
'decryptionKey',
|
|
324
|
+
undefined,
|
|
325
|
+
ErrorCode.INVALID_KEY
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Derive encryption key (same process as encryption)
|
|
330
|
+
const derivedKey = this.deriveEncryptionKey(decryptionKey, vote.proposalId)
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// Verify key hash matches (optional but helpful error message)
|
|
334
|
+
const keyHash = sha256(hexToBytes(decryptionKey.slice(2)))
|
|
335
|
+
const expectedKeyHash = `0x${bytesToHex(keyHash)}` as HexString
|
|
336
|
+
|
|
337
|
+
if (vote.encryptionKeyHash !== expectedKeyHash) {
|
|
338
|
+
throw new CryptoError(
|
|
339
|
+
'Decryption key hash mismatch - this key cannot decrypt this vote',
|
|
340
|
+
ErrorCode.DECRYPTION_FAILED,
|
|
341
|
+
{ operation: 'revealVote' }
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Parse nonce and ciphertext
|
|
346
|
+
const nonceHex = vote.nonce.startsWith('0x') ? vote.nonce.slice(2) : vote.nonce
|
|
347
|
+
const nonce = hexToBytes(nonceHex)
|
|
348
|
+
|
|
349
|
+
const ciphertextHex = vote.ciphertext.startsWith('0x')
|
|
350
|
+
? vote.ciphertext.slice(2)
|
|
351
|
+
: vote.ciphertext
|
|
352
|
+
const ciphertext = hexToBytes(ciphertextHex)
|
|
353
|
+
|
|
354
|
+
// Decrypt with XChaCha20-Poly1305
|
|
355
|
+
const cipher = xchacha20poly1305(derivedKey, nonce)
|
|
356
|
+
let plaintext: Uint8Array
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
plaintext = cipher.decrypt(ciphertext)
|
|
360
|
+
} catch (e) {
|
|
361
|
+
throw new CryptoError(
|
|
362
|
+
'Decryption failed - authentication tag verification failed. ' +
|
|
363
|
+
'Either the decryption key is incorrect or the vote has been tampered with.',
|
|
364
|
+
ErrorCode.DECRYPTION_FAILED,
|
|
365
|
+
{
|
|
366
|
+
cause: e instanceof Error ? e : undefined,
|
|
367
|
+
operation: 'revealVote',
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Parse JSON
|
|
373
|
+
const textDecoder = new TextDecoder()
|
|
374
|
+
const jsonString = textDecoder.decode(plaintext)
|
|
375
|
+
|
|
376
|
+
// Validate size
|
|
377
|
+
if (jsonString.length > MAX_VOTE_DATA_SIZE) {
|
|
378
|
+
throw new ValidationError(
|
|
379
|
+
`decrypted vote data exceeds maximum size limit (${MAX_VOTE_DATA_SIZE} bytes)`,
|
|
380
|
+
'voteData',
|
|
381
|
+
{ received: jsonString.length, max: MAX_VOTE_DATA_SIZE },
|
|
382
|
+
ErrorCode.INVALID_INPUT
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Parse and validate vote data
|
|
387
|
+
let voteData: VoteData
|
|
388
|
+
try {
|
|
389
|
+
voteData = JSON.parse(jsonString) as VoteData
|
|
390
|
+
} catch (e) {
|
|
391
|
+
if (e instanceof SyntaxError) {
|
|
392
|
+
throw new CryptoError(
|
|
393
|
+
'Decryption succeeded but vote data is malformed JSON',
|
|
394
|
+
ErrorCode.DECRYPTION_FAILED,
|
|
395
|
+
{ cause: e, operation: 'revealVote' }
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
throw e
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Validate required fields
|
|
402
|
+
if (
|
|
403
|
+
typeof voteData.proposalId !== 'string' ||
|
|
404
|
+
typeof voteData.choice !== 'number' ||
|
|
405
|
+
typeof voteData.weight !== 'string' ||
|
|
406
|
+
typeof voteData.voter !== 'string' ||
|
|
407
|
+
typeof voteData.timestamp !== 'number'
|
|
408
|
+
) {
|
|
409
|
+
throw new ValidationError(
|
|
410
|
+
'invalid vote data format',
|
|
411
|
+
'voteData',
|
|
412
|
+
{ received: voteData },
|
|
413
|
+
ErrorCode.INVALID_INPUT
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Verify proposal ID matches
|
|
418
|
+
if (voteData.proposalId !== vote.proposalId) {
|
|
419
|
+
throw new ValidationError(
|
|
420
|
+
'proposal ID mismatch between encrypted vote and decrypted data',
|
|
421
|
+
'proposalId',
|
|
422
|
+
{ encrypted: vote.proposalId, decrypted: voteData.proposalId },
|
|
423
|
+
ErrorCode.INVALID_INPUT
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Parse weight from string
|
|
428
|
+
let weight: bigint
|
|
429
|
+
try {
|
|
430
|
+
weight = BigInt(voteData.weight)
|
|
431
|
+
} catch (e) {
|
|
432
|
+
throw new ValidationError(
|
|
433
|
+
'invalid weight value',
|
|
434
|
+
'weight',
|
|
435
|
+
{ received: voteData.weight },
|
|
436
|
+
ErrorCode.INVALID_AMOUNT
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
proposalId: voteData.proposalId,
|
|
442
|
+
choice: voteData.choice,
|
|
443
|
+
weight,
|
|
444
|
+
voter: voteData.voter,
|
|
445
|
+
timestamp: voteData.timestamp,
|
|
446
|
+
encryptedVote: vote,
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
// Securely wipe derived key after use
|
|
450
|
+
secureWipe(derivedKey)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Derive encryption key from provided key using HKDF
|
|
456
|
+
*
|
|
457
|
+
* Uses HKDF-SHA256 with domain separation for security.
|
|
458
|
+
* Incorporates proposal ID for key binding.
|
|
459
|
+
*
|
|
460
|
+
* @param key - Source encryption key
|
|
461
|
+
* @param proposalId - Proposal ID for key binding
|
|
462
|
+
* @returns 32-byte derived encryption key (caller must wipe after use)
|
|
463
|
+
*/
|
|
464
|
+
private deriveEncryptionKey(key: string, proposalId: string): Uint8Array {
|
|
465
|
+
// Extract the raw key bytes (remove 0x prefix if present)
|
|
466
|
+
const keyHex = key.startsWith('0x') ? key.slice(2) : key
|
|
467
|
+
const keyBytes = hexToBytes(keyHex)
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
// Use HKDF to derive a proper encryption key
|
|
471
|
+
// HKDF(SHA256, ikm=key, salt=domain, info=proposalId, length=32)
|
|
472
|
+
const salt = utf8ToBytes(VOTE_ENCRYPTION_DOMAIN)
|
|
473
|
+
const info = utf8ToBytes(proposalId)
|
|
474
|
+
|
|
475
|
+
return hkdf(sha256, keyBytes, salt, info, 32)
|
|
476
|
+
} finally {
|
|
477
|
+
// Securely wipe source key bytes
|
|
478
|
+
secureWipe(keyBytes)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Validate cast vote parameters
|
|
484
|
+
*/
|
|
485
|
+
private validateCastVoteParams(params: CastVoteParams): void {
|
|
486
|
+
const { proposalId, choice, weight, encryptionKey, voter } = params
|
|
487
|
+
|
|
488
|
+
// Validate proposal ID
|
|
489
|
+
if (typeof proposalId !== 'string' || proposalId.length === 0) {
|
|
490
|
+
throw new ValidationError(
|
|
491
|
+
'proposalId must be a non-empty string',
|
|
492
|
+
'proposalId',
|
|
493
|
+
undefined,
|
|
494
|
+
ErrorCode.MISSING_REQUIRED
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Validate choice
|
|
499
|
+
if (typeof choice !== 'number' || !Number.isInteger(choice) || choice < 0) {
|
|
500
|
+
throw new ValidationError(
|
|
501
|
+
'choice must be a non-negative integer',
|
|
502
|
+
'choice',
|
|
503
|
+
{ received: choice },
|
|
504
|
+
ErrorCode.INVALID_INPUT
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Validate weight
|
|
509
|
+
if (typeof weight !== 'bigint') {
|
|
510
|
+
throw new ValidationError(
|
|
511
|
+
'weight must be a bigint',
|
|
512
|
+
'weight',
|
|
513
|
+
{ received: typeof weight },
|
|
514
|
+
ErrorCode.INVALID_AMOUNT
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (weight < 0n) {
|
|
519
|
+
throw new ValidationError(
|
|
520
|
+
'weight must be non-negative',
|
|
521
|
+
'weight',
|
|
522
|
+
{ received: weight.toString() },
|
|
523
|
+
ErrorCode.INVALID_AMOUNT
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Validate encryption key
|
|
528
|
+
if (!isValidHex(encryptionKey)) {
|
|
529
|
+
throw new ValidationError(
|
|
530
|
+
'encryptionKey must be a valid hex string with 0x prefix',
|
|
531
|
+
'encryptionKey',
|
|
532
|
+
undefined,
|
|
533
|
+
ErrorCode.INVALID_KEY
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Validate voter (optional)
|
|
538
|
+
if (voter !== undefined && typeof voter !== 'string') {
|
|
539
|
+
throw new ValidationError(
|
|
540
|
+
'voter must be a string',
|
|
541
|
+
'voter',
|
|
542
|
+
{ received: typeof voter },
|
|
543
|
+
ErrorCode.INVALID_INPUT
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Tally votes homomorphically
|
|
550
|
+
*
|
|
551
|
+
* Aggregates encrypted votes by summing Pedersen commitments for each choice.
|
|
552
|
+
* Individual votes remain hidden - only the final tally can be revealed.
|
|
553
|
+
*
|
|
554
|
+
* This leverages the homomorphic property of Pedersen commitments:
|
|
555
|
+
* C(v1) + C(v2) = C(v1 + v2) when blindings are properly tracked.
|
|
556
|
+
*
|
|
557
|
+
* **Note:** In this simplified implementation, we reveal individual votes to
|
|
558
|
+
* compute commitments for each choice. A full production implementation would
|
|
559
|
+
* use commitments directly from votes without decryption.
|
|
560
|
+
*
|
|
561
|
+
* @param votes - Array of encrypted votes to tally
|
|
562
|
+
* @param decryptionKey - Key to decrypt votes (committee key)
|
|
563
|
+
* @returns Encrypted tally with aggregated commitments per choice
|
|
564
|
+
*
|
|
565
|
+
* @throws {ValidationError} If votes array is empty or has inconsistent proposal IDs
|
|
566
|
+
* @throws {CryptoError} If decryption fails
|
|
567
|
+
*
|
|
568
|
+
* @example
|
|
569
|
+
* ```typescript
|
|
570
|
+
* const voting = new PrivateVoting()
|
|
571
|
+
* const encryptionKey = generateRandomBytes(32)
|
|
572
|
+
*
|
|
573
|
+
* // Cast multiple votes
|
|
574
|
+
* const votes = [
|
|
575
|
+
* voting.castVote({ proposalId: 'p1', choice: 0, weight: 100n, encryptionKey }),
|
|
576
|
+
* voting.castVote({ proposalId: 'p1', choice: 1, weight: 200n, encryptionKey }),
|
|
577
|
+
* voting.castVote({ proposalId: 'p1', choice: 0, weight: 150n, encryptionKey }),
|
|
578
|
+
* ]
|
|
579
|
+
*
|
|
580
|
+
* // Tally homomorphically
|
|
581
|
+
* const tally = voting.tallyVotes(votes, encryptionKey)
|
|
582
|
+
* // tally contains: choice 0 -> commitment(250), choice 1 -> commitment(200)
|
|
583
|
+
* ```
|
|
584
|
+
*/
|
|
585
|
+
tallyVotes(votes: EncryptedVote[], decryptionKey: string): EncryptedTally {
|
|
586
|
+
// Validate inputs
|
|
587
|
+
if (!Array.isArray(votes)) {
|
|
588
|
+
throw new ValidationError(
|
|
589
|
+
'votes must be an array',
|
|
590
|
+
'votes',
|
|
591
|
+
undefined,
|
|
592
|
+
ErrorCode.INVALID_INPUT
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (votes.length === 0) {
|
|
597
|
+
throw new ValidationError(
|
|
598
|
+
'votes array cannot be empty',
|
|
599
|
+
'votes',
|
|
600
|
+
undefined,
|
|
601
|
+
ErrorCode.INVALID_INPUT
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Validate all votes are for the same proposal
|
|
606
|
+
const proposalId = votes[0].proposalId
|
|
607
|
+
for (const vote of votes) {
|
|
608
|
+
if (vote.proposalId !== proposalId) {
|
|
609
|
+
throw new ValidationError(
|
|
610
|
+
'all votes must be for the same proposal',
|
|
611
|
+
'votes',
|
|
612
|
+
{ expected: proposalId, received: vote.proposalId },
|
|
613
|
+
ErrorCode.INVALID_INPUT
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Validate decryption key
|
|
619
|
+
if (!isValidHex(decryptionKey)) {
|
|
620
|
+
throw new ValidationError(
|
|
621
|
+
'decryptionKey must be a valid hex string with 0x prefix',
|
|
622
|
+
'decryptionKey',
|
|
623
|
+
undefined,
|
|
624
|
+
ErrorCode.INVALID_KEY
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Decrypt all votes and group by choice
|
|
629
|
+
const votesByChoice: Record<string, bigint[]> = {}
|
|
630
|
+
|
|
631
|
+
for (const encryptedVote of votes) {
|
|
632
|
+
const revealed = this.revealVote(encryptedVote, decryptionKey)
|
|
633
|
+
const choiceKey = revealed.choice.toString()
|
|
634
|
+
|
|
635
|
+
if (!votesByChoice[choiceKey]) {
|
|
636
|
+
votesByChoice[choiceKey] = []
|
|
637
|
+
}
|
|
638
|
+
votesByChoice[choiceKey].push(revealed.weight)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Create commitments for each choice's total
|
|
642
|
+
const tallies: Record<string, HexString> = {}
|
|
643
|
+
const blindings: Record<string, HexString> = {}
|
|
644
|
+
|
|
645
|
+
for (const [choice, weights] of Object.entries(votesByChoice)) {
|
|
646
|
+
// Sum weights for this choice
|
|
647
|
+
const totalWeight = weights.reduce((sum, w) => sum + w, 0n)
|
|
648
|
+
|
|
649
|
+
// Create Pedersen commitment to the total
|
|
650
|
+
// In production, you'd aggregate commitments directly without revealing
|
|
651
|
+
// Here we commit to the sum for simplicity
|
|
652
|
+
const { commitment, blinding } = commit(totalWeight, hexToBytes(generateBlinding().slice(2)))
|
|
653
|
+
tallies[choice] = commitment
|
|
654
|
+
blindings[choice] = blinding
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Encrypt the blinding factors with the decryption key
|
|
658
|
+
// This allows the committee to later reveal the tally
|
|
659
|
+
const encryptedBlindings: Record<string, { ciphertext: HexString; nonce: HexString }> = {}
|
|
660
|
+
|
|
661
|
+
for (const [choice, blinding] of Object.entries(blindings)) {
|
|
662
|
+
// Generate nonce for encryption
|
|
663
|
+
const nonce = randomBytes(NONCE_SIZE)
|
|
664
|
+
|
|
665
|
+
// Derive encryption key
|
|
666
|
+
const derivedKey = this.deriveEncryptionKey(decryptionKey, `${proposalId}-tally-${choice}`)
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
// Encrypt the blinding factor
|
|
670
|
+
const cipher = xchacha20poly1305(derivedKey, nonce)
|
|
671
|
+
const blindingBytes = hexToBytes(blinding.slice(2))
|
|
672
|
+
const ciphertext = cipher.encrypt(blindingBytes)
|
|
673
|
+
|
|
674
|
+
encryptedBlindings[choice] = {
|
|
675
|
+
ciphertext: `0x${bytesToHex(ciphertext)}` as HexString,
|
|
676
|
+
nonce: `0x${bytesToHex(nonce)}` as HexString,
|
|
677
|
+
}
|
|
678
|
+
} finally {
|
|
679
|
+
secureWipe(derivedKey)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
proposalId,
|
|
685
|
+
tallies,
|
|
686
|
+
encryptedBlindings,
|
|
687
|
+
voteCount: votes.length,
|
|
688
|
+
timestamp: Date.now(),
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Reveal the final tally using threshold decryption
|
|
694
|
+
*
|
|
695
|
+
* In a full threshold cryptography implementation, t-of-n committee members
|
|
696
|
+
* would each provide a decryption share. When enough shares are collected,
|
|
697
|
+
* the tally can be revealed.
|
|
698
|
+
*
|
|
699
|
+
* **Note:** This simplified implementation uses a single decryption key.
|
|
700
|
+
* A production system would implement proper threshold secret sharing
|
|
701
|
+
* (e.g., Shamir's Secret Sharing) for committee-based decryption.
|
|
702
|
+
*
|
|
703
|
+
* @param tally - Encrypted tally to reveal
|
|
704
|
+
* @param decryptionShares - Decryption shares from committee members
|
|
705
|
+
* @returns Final tally results with revealed vote counts per choice
|
|
706
|
+
*
|
|
707
|
+
* @throws {ValidationError} If tally is invalid or insufficient shares provided
|
|
708
|
+
* @throws {CryptoError} If threshold reconstruction fails
|
|
709
|
+
*
|
|
710
|
+
* @example
|
|
711
|
+
* ```typescript
|
|
712
|
+
* const voting = new PrivateVoting()
|
|
713
|
+
*
|
|
714
|
+
* // After tallying...
|
|
715
|
+
* const shares = [
|
|
716
|
+
* { memberId: 'member1', share: '0xabc...' },
|
|
717
|
+
* { memberId: 'member2', share: '0xdef...' },
|
|
718
|
+
* { memberId: 'member3', share: '0x123...' },
|
|
719
|
+
* ]
|
|
720
|
+
*
|
|
721
|
+
* const results = voting.revealTally(encryptedTally, shares)
|
|
722
|
+
* console.log(results.results) // { "0": 250n, "1": 200n }
|
|
723
|
+
* ```
|
|
724
|
+
*/
|
|
725
|
+
revealTally(
|
|
726
|
+
tally: EncryptedTally,
|
|
727
|
+
decryptionShares: DecryptionShare[],
|
|
728
|
+
): TallyResult {
|
|
729
|
+
// Validate tally
|
|
730
|
+
this.validateEncryptedTally(tally)
|
|
731
|
+
|
|
732
|
+
// Validate decryption shares
|
|
733
|
+
if (!Array.isArray(decryptionShares)) {
|
|
734
|
+
throw new ValidationError(
|
|
735
|
+
'decryptionShares must be an array',
|
|
736
|
+
'decryptionShares',
|
|
737
|
+
undefined,
|
|
738
|
+
ErrorCode.INVALID_INPUT
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (decryptionShares.length === 0) {
|
|
743
|
+
throw new ValidationError(
|
|
744
|
+
'must provide at least one decryption share',
|
|
745
|
+
'decryptionShares',
|
|
746
|
+
undefined,
|
|
747
|
+
ErrorCode.INVALID_INPUT
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Validate each share
|
|
752
|
+
for (const share of decryptionShares) {
|
|
753
|
+
if (!share || typeof share !== 'object') {
|
|
754
|
+
throw new ValidationError(
|
|
755
|
+
'each decryption share must be an object',
|
|
756
|
+
'decryptionShares',
|
|
757
|
+
undefined,
|
|
758
|
+
ErrorCode.INVALID_INPUT
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (typeof share.memberId !== 'string' || share.memberId.length === 0) {
|
|
763
|
+
throw new ValidationError(
|
|
764
|
+
'each share must have a non-empty memberId',
|
|
765
|
+
'decryptionShares.memberId',
|
|
766
|
+
undefined,
|
|
767
|
+
ErrorCode.INVALID_INPUT
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (!isValidHex(share.share)) {
|
|
772
|
+
throw new ValidationError(
|
|
773
|
+
'each share.share must be a valid hex string',
|
|
774
|
+
'decryptionShares.share',
|
|
775
|
+
undefined,
|
|
776
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
777
|
+
)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// In this simplified implementation, we reconstruct the decryption key
|
|
782
|
+
// from the shares. In production, this would use Shamir's Secret Sharing
|
|
783
|
+
// or a proper threshold scheme.
|
|
784
|
+
//
|
|
785
|
+
// For now, we'll use XOR reconstruction (simplified 1-of-n threshold)
|
|
786
|
+
let reconstructedKey: Uint8Array | null = null
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
// Simple XOR reconstruction (toy example)
|
|
790
|
+
reconstructedKey = hexToBytes(decryptionShares[0].share.slice(2))
|
|
791
|
+
|
|
792
|
+
for (let i = 1; i < decryptionShares.length; i++) {
|
|
793
|
+
const shareBytes = hexToBytes(decryptionShares[i].share.slice(2))
|
|
794
|
+
|
|
795
|
+
if (shareBytes.length !== reconstructedKey.length) {
|
|
796
|
+
throw new ValidationError(
|
|
797
|
+
'all decryption shares must have the same length',
|
|
798
|
+
'decryptionShares',
|
|
799
|
+
undefined,
|
|
800
|
+
ErrorCode.INVALID_INPUT
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
for (let j = 0; j < reconstructedKey.length; j++) {
|
|
805
|
+
reconstructedKey[j] ^= shareBytes[j]
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Convert reconstructed key to hex
|
|
810
|
+
const reconstructedKeyHex = `0x${bytesToHex(reconstructedKey)}` as HexString
|
|
811
|
+
|
|
812
|
+
// Decrypt blinding factors and brute-force search for values
|
|
813
|
+
const results: Record<string, bigint> = {}
|
|
814
|
+
|
|
815
|
+
for (const [choice, commitmentPoint] of Object.entries(tally.tallies)) {
|
|
816
|
+
// Decrypt the blinding factor for this choice
|
|
817
|
+
const encBlinding = tally.encryptedBlindings[choice]
|
|
818
|
+
if (!encBlinding) {
|
|
819
|
+
throw new CryptoError(
|
|
820
|
+
`missing encrypted blinding factor for choice ${choice}`,
|
|
821
|
+
ErrorCode.DECRYPTION_FAILED,
|
|
822
|
+
{ operation: 'revealTally', context: { choice } }
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Derive decryption key for this choice's blinding
|
|
827
|
+
const derivedKey = this.deriveEncryptionKey(
|
|
828
|
+
reconstructedKeyHex,
|
|
829
|
+
`${tally.proposalId}-tally-${choice}`
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
let blindingFactor: HexString
|
|
833
|
+
try {
|
|
834
|
+
// Decrypt the blinding factor
|
|
835
|
+
const nonceBytes = hexToBytes(encBlinding.nonce.slice(2))
|
|
836
|
+
const ciphertextBytes = hexToBytes(encBlinding.ciphertext.slice(2))
|
|
837
|
+
|
|
838
|
+
const cipher = xchacha20poly1305(derivedKey, nonceBytes)
|
|
839
|
+
const blindingBytes = cipher.decrypt(ciphertextBytes)
|
|
840
|
+
|
|
841
|
+
blindingFactor = `0x${bytesToHex(blindingBytes)}` as HexString
|
|
842
|
+
} catch (e) {
|
|
843
|
+
throw new CryptoError(
|
|
844
|
+
'failed to decrypt blinding factor',
|
|
845
|
+
ErrorCode.DECRYPTION_FAILED,
|
|
846
|
+
{
|
|
847
|
+
cause: e instanceof Error ? e : undefined,
|
|
848
|
+
operation: 'revealTally',
|
|
849
|
+
context: { choice },
|
|
850
|
+
}
|
|
851
|
+
)
|
|
852
|
+
} finally {
|
|
853
|
+
secureWipe(derivedKey)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Now brute-force search for the value
|
|
857
|
+
// In production, you'd use ZK range proofs to avoid this
|
|
858
|
+
let found = false
|
|
859
|
+
const maxTries = 1000000n // Reasonable limit for vote counts
|
|
860
|
+
|
|
861
|
+
for (let value = 0n; value <= maxTries; value++) {
|
|
862
|
+
try {
|
|
863
|
+
// Try to create a commitment with this value and the decrypted blinding
|
|
864
|
+
const { commitment: testCommit } = commit(
|
|
865
|
+
value,
|
|
866
|
+
hexToBytes(blindingFactor.slice(2))
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
// Check if it matches
|
|
870
|
+
if (testCommit === commitmentPoint) {
|
|
871
|
+
results[choice] = value
|
|
872
|
+
found = true
|
|
873
|
+
break
|
|
874
|
+
}
|
|
875
|
+
} catch {
|
|
876
|
+
// Invalid commitment, continue
|
|
877
|
+
continue
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (!found) {
|
|
882
|
+
throw new CryptoError(
|
|
883
|
+
'failed to reveal tally - value exceeds searchable range',
|
|
884
|
+
ErrorCode.DECRYPTION_FAILED,
|
|
885
|
+
{ operation: 'revealTally', context: { choice, maxTries: maxTries.toString() } }
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
proposalId: tally.proposalId,
|
|
892
|
+
results,
|
|
893
|
+
voteCount: tally.voteCount,
|
|
894
|
+
timestamp: Date.now(),
|
|
895
|
+
encryptedTally: tally,
|
|
896
|
+
}
|
|
897
|
+
} catch (e) {
|
|
898
|
+
if (e instanceof ValidationError || e instanceof CryptoError) {
|
|
899
|
+
throw e
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
throw new CryptoError(
|
|
903
|
+
'threshold decryption failed',
|
|
904
|
+
ErrorCode.DECRYPTION_FAILED,
|
|
905
|
+
{
|
|
906
|
+
cause: e instanceof Error ? e : undefined,
|
|
907
|
+
operation: 'revealTally',
|
|
908
|
+
}
|
|
909
|
+
)
|
|
910
|
+
} finally {
|
|
911
|
+
// Securely wipe reconstructed key
|
|
912
|
+
if (reconstructedKey) {
|
|
913
|
+
secureWipe(reconstructedKey)
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Validate encrypted tally structure
|
|
920
|
+
*/
|
|
921
|
+
private validateEncryptedTally(tally: EncryptedTally): void {
|
|
922
|
+
if (!tally || typeof tally !== 'object') {
|
|
923
|
+
throw new ValidationError(
|
|
924
|
+
'tally must be an object',
|
|
925
|
+
'tally',
|
|
926
|
+
undefined,
|
|
927
|
+
ErrorCode.INVALID_INPUT
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (typeof tally.proposalId !== 'string' || tally.proposalId.length === 0) {
|
|
932
|
+
throw new ValidationError(
|
|
933
|
+
'proposalId must be a non-empty string',
|
|
934
|
+
'tally.proposalId',
|
|
935
|
+
undefined,
|
|
936
|
+
ErrorCode.INVALID_INPUT
|
|
937
|
+
)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (!tally.tallies || typeof tally.tallies !== 'object') {
|
|
941
|
+
throw new ValidationError(
|
|
942
|
+
'tallies must be an object',
|
|
943
|
+
'tally.tallies',
|
|
944
|
+
undefined,
|
|
945
|
+
ErrorCode.INVALID_INPUT
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Validate each tally commitment
|
|
950
|
+
for (const [choice, commitment] of Object.entries(tally.tallies)) {
|
|
951
|
+
if (!isValidHex(commitment)) {
|
|
952
|
+
throw new ValidationError(
|
|
953
|
+
`tally for choice ${choice} must be a valid hex string`,
|
|
954
|
+
'tally.tallies',
|
|
955
|
+
undefined,
|
|
956
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
957
|
+
)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Validate encrypted blindings
|
|
962
|
+
if (!tally.encryptedBlindings || typeof tally.encryptedBlindings !== 'object') {
|
|
963
|
+
throw new ValidationError(
|
|
964
|
+
'encryptedBlindings must be an object',
|
|
965
|
+
'tally.encryptedBlindings',
|
|
966
|
+
undefined,
|
|
967
|
+
ErrorCode.INVALID_INPUT
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Validate each encrypted blinding
|
|
972
|
+
for (const [choice, encBlinding] of Object.entries(tally.encryptedBlindings)) {
|
|
973
|
+
if (!encBlinding || typeof encBlinding !== 'object') {
|
|
974
|
+
throw new ValidationError(
|
|
975
|
+
`encrypted blinding for choice ${choice} must be an object`,
|
|
976
|
+
'tally.encryptedBlindings',
|
|
977
|
+
undefined,
|
|
978
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
979
|
+
)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (!isValidHex(encBlinding.ciphertext)) {
|
|
983
|
+
throw new ValidationError(
|
|
984
|
+
`encrypted blinding ciphertext for choice ${choice} must be a valid hex string`,
|
|
985
|
+
'tally.encryptedBlindings.ciphertext',
|
|
986
|
+
undefined,
|
|
987
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (!isValidHex(encBlinding.nonce)) {
|
|
992
|
+
throw new ValidationError(
|
|
993
|
+
`encrypted blinding nonce for choice ${choice} must be a valid hex string`,
|
|
994
|
+
'tally.encryptedBlindings.nonce',
|
|
995
|
+
undefined,
|
|
996
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
997
|
+
)
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (typeof tally.voteCount !== 'number' || !Number.isInteger(tally.voteCount) || tally.voteCount < 0) {
|
|
1002
|
+
throw new ValidationError(
|
|
1003
|
+
'voteCount must be a non-negative integer',
|
|
1004
|
+
'tally.voteCount',
|
|
1005
|
+
{ received: tally.voteCount },
|
|
1006
|
+
ErrorCode.INVALID_INPUT
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (typeof tally.timestamp !== 'number' || !Number.isInteger(tally.timestamp)) {
|
|
1011
|
+
throw new ValidationError(
|
|
1012
|
+
'timestamp must be an integer',
|
|
1013
|
+
'tally.timestamp',
|
|
1014
|
+
{ received: tally.timestamp },
|
|
1015
|
+
ErrorCode.INVALID_INPUT
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Validate encrypted vote structure
|
|
1022
|
+
*/
|
|
1023
|
+
private validateEncryptedVote(vote: EncryptedVote): void {
|
|
1024
|
+
if (!vote || typeof vote !== 'object') {
|
|
1025
|
+
throw new ValidationError(
|
|
1026
|
+
'vote must be an object',
|
|
1027
|
+
'vote',
|
|
1028
|
+
undefined,
|
|
1029
|
+
ErrorCode.INVALID_INPUT
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Validate ciphertext
|
|
1034
|
+
if (!isValidHex(vote.ciphertext)) {
|
|
1035
|
+
throw new ValidationError(
|
|
1036
|
+
'ciphertext must be a valid hex string',
|
|
1037
|
+
'vote.ciphertext',
|
|
1038
|
+
undefined,
|
|
1039
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
1040
|
+
)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Validate nonce
|
|
1044
|
+
if (!isValidHex(vote.nonce)) {
|
|
1045
|
+
throw new ValidationError(
|
|
1046
|
+
'nonce must be a valid hex string',
|
|
1047
|
+
'vote.nonce',
|
|
1048
|
+
undefined,
|
|
1049
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Validate encryption key hash
|
|
1054
|
+
if (!isValidHex(vote.encryptionKeyHash)) {
|
|
1055
|
+
throw new ValidationError(
|
|
1056
|
+
'encryptionKeyHash must be a valid hex string',
|
|
1057
|
+
'vote.encryptionKeyHash',
|
|
1058
|
+
undefined,
|
|
1059
|
+
ErrorCode.INVALID_ENCRYPTED_DATA
|
|
1060
|
+
)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Validate proposal ID
|
|
1064
|
+
if (typeof vote.proposalId !== 'string' || vote.proposalId.length === 0) {
|
|
1065
|
+
throw new ValidationError(
|
|
1066
|
+
'proposalId must be a non-empty string',
|
|
1067
|
+
'vote.proposalId',
|
|
1068
|
+
undefined,
|
|
1069
|
+
ErrorCode.INVALID_INPUT
|
|
1070
|
+
)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Validate voter
|
|
1074
|
+
if (typeof vote.voter !== 'string') {
|
|
1075
|
+
throw new ValidationError(
|
|
1076
|
+
'voter must be a string',
|
|
1077
|
+
'vote.voter',
|
|
1078
|
+
{ received: typeof vote.voter },
|
|
1079
|
+
ErrorCode.INVALID_INPUT
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Validate timestamp
|
|
1084
|
+
if (typeof vote.timestamp !== 'number' || !Number.isInteger(vote.timestamp)) {
|
|
1085
|
+
throw new ValidationError(
|
|
1086
|
+
'timestamp must be an integer',
|
|
1087
|
+
'vote.timestamp',
|
|
1088
|
+
{ received: vote.timestamp },
|
|
1089
|
+
ErrorCode.INVALID_INPUT
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Create a new PrivateVoting instance
|
|
1097
|
+
*
|
|
1098
|
+
* @returns PrivateVoting instance
|
|
1099
|
+
*
|
|
1100
|
+
* @example
|
|
1101
|
+
* ```typescript
|
|
1102
|
+
* import { createPrivateVoting } from '@sip-protocol/sdk'
|
|
1103
|
+
*
|
|
1104
|
+
* const voting = createPrivateVoting()
|
|
1105
|
+
*
|
|
1106
|
+
* const encryptedVote = voting.castVote({
|
|
1107
|
+
* proposalId: 'proposal-123',
|
|
1108
|
+
* choice: 1,
|
|
1109
|
+
* weight: 1000n,
|
|
1110
|
+
* encryptionKey: '0xabc...',
|
|
1111
|
+
* })
|
|
1112
|
+
* ```
|
|
1113
|
+
*/
|
|
1114
|
+
export function createPrivateVoting(): PrivateVoting {
|
|
1115
|
+
return new PrivateVoting()
|
|
1116
|
+
}
|