From e69654fcfe7e75180a4cd562247139de08c98f59 Mon Sep 17 00:00:00 2001 From: James Campbell Date: Wed, 5 Nov 2025 13:25:44 +0000 Subject: [PATCH] Add end-to-end encryption for signing requests Implements E2E encryption for threshold signing requests, mirroring the existing pattern used for decryption requests. Key changes: - Add makeSigningRequests() helper to encrypt signing requests using ephemeral session keys and ECDH-derived shared secrets - Add decryptSigningResponses() helper to decrypt signing responses - Update SigningCoordinatorAgent.getParticipants() to extract signingRequestStaticKey from contract - Update PorterClient.signUserOp() to use encrypted requests/responses - Remove old plaintext signing types and decodeSignature() function - Update tests with signingRequestStaticKey mocks The encryption is transparent to users - the public API (signUserOp) remains unchanged. Depends on: - nucypher-core PR #116 (encryption types) - nucypher-contracts PR #438 (signingRequestStaticKey field) - nucypher PR #3666 (Porter encrypted signing endpoint) --- .../contracts/agents/signing-coordinator.ts | 9 + packages/shared/src/porter.ts | 107 +++++------ packages/taco/src/sign.ts | 171 ++++++++++++++++-- packages/taco/test/taco-sign.test.ts | 38 +++- 4 files changed, 243 insertions(+), 82 deletions(-) diff --git a/packages/shared/src/contracts/agents/signing-coordinator.ts b/packages/shared/src/contracts/agents/signing-coordinator.ts index 251647576..99e3e3b44 100644 --- a/packages/shared/src/contracts/agents/signing-coordinator.ts +++ b/packages/shared/src/contracts/agents/signing-coordinator.ts @@ -1,7 +1,9 @@ import { getContract } from '@nucypher/nucypher-contracts'; +import { SessionStaticKey } from '@nucypher/nucypher-core'; import { ethers } from 'ethers'; import { Domain } from '../../porter'; +import { fromHexString } from '../../utils'; import { SigningCoordinator__factory } from '../ethers-typechain'; import { SigningCoordinator } from '../ethers-typechain/SigningCoordinator'; @@ -9,6 +11,7 @@ type SignerInfo = { operator: string; provider: string; signature: string; + signingRequestStaticKey: SessionStaticKey; }; export class SigningCoordinatorAgent { @@ -24,10 +27,16 @@ export class SigningCoordinatorAgent { ( participant: SigningCoordinator.SigningCohortParticipantStructOutput, ) => { + // Extract signingRequestStaticKey from contract + const signingRequestStaticKey = SessionStaticKey.fromBytes( + fromHexString(participant.signingRequestStaticKey), + ); + return { operator: participant.operator, provider: participant.provider, signature: participant.signature, + signingRequestStaticKey, }; }, ); diff --git a/packages/shared/src/porter.ts b/packages/shared/src/porter.ts index e658c19a2..b8778f853 100644 --- a/packages/shared/src/porter.ts +++ b/packages/shared/src/porter.ts @@ -2,6 +2,8 @@ import { CapsuleFrag, EncryptedThresholdDecryptionRequest, EncryptedThresholdDecryptionResponse, + EncryptedThresholdSigningRequest, + EncryptedThresholdSigningResponse, PublicKey, RetrievalKit, TreasureMap, @@ -153,12 +155,12 @@ export type TacoDecryptResult = { }; // Signing types -type TacoSignResponse = { +type TacoSignResponseEncrypted = { readonly result: { readonly signing_results: { - readonly signatures: Record< + readonly encrypted_signing_responses: Record< ChecksumAddress, - [ChecksumAddress, Base64EncodedBytes] + Base64EncodedBytes >; readonly errors: Record; }; @@ -171,33 +173,11 @@ export type TacoSignature = { signerAddress: string; }; -export type TacoSignResult = { - signingResults: { [ursulaAddress: string]: TacoSignature }; +export type TacoSignResultEncrypted = { + encryptedResponses: Record; errors: Record; }; -function decodeSignature( - signerAddress: string, - signatureB64: string, -): { result?: TacoSignature; error?: string } { - try { - const decodedData = JSON.parse( - new TextDecoder().decode(fromBase64(signatureB64)), - ); - return { - result: { - messageHash: decodedData.message_hash, - signature: decodedData.signature, - signerAddress, - }, - }; - } catch (error) { - return { - error: `Failed to decode signature: ${error}`, - }; - } -} - export class PorterClient { readonly porterUrls: URL[]; @@ -344,41 +324,54 @@ export class PorterClient { return { encryptedResponses, errors }; } + /** + * Signs a UserOperation using encrypted signing requests. + * This method mirrors the pattern used by tacoDecrypt for encrypted decryption requests. + * + * @param encryptedRequests - Encrypted signing requests for each signer + * @param threshold - Minimum number of signatures required + * @returns Encrypted signing responses and errors + */ public async signUserOp( - signingRequests: Record, + encryptedRequests: Record, threshold: number, - ): Promise { - const data: Record = { - signing_requests: signingRequests, - threshold: threshold, + ): Promise { + const data = { + encrypted_signing_requests: Object.fromEntries( + Object.entries(encryptedRequests).map( + ([provider, encryptedRequest]) => [ + provider, + toBase64(encryptedRequest.toBytes()), + ], + ), + ), + threshold, }; - const resp: AxiosResponse = await this.tryAndCall({ - url: '/sign', - method: 'post', - data, - }); + const resp: AxiosResponse = + await this.tryAndCall({ + url: '/sign', + method: 'post', + data, + }); - const { signatures, errors } = resp.data.result.signing_results; - const allErrors: Record = { ...errors }; - - const signingResults: { [ursulaAddress: string]: TacoSignature } = {}; - for (const [ursulaAddress, [signerAddress, signatureB64]] of Object.entries( - signatures || {}, - )) { - const decoded = decodeSignature(signerAddress, signatureB64); - if (decoded.error) { - // issue with decoding signature, add to errors - allErrors[ursulaAddress] = decoded.error; - continue; - } - // Always include all decoded signatures in signingResults - signingResults[ursulaAddress] = decoded.result!; - } + const { encrypted_signing_responses, errors } = + resp.data.result.signing_results; - return { - signingResults, - errors: allErrors, - }; + const signingResponses = Object.entries(encrypted_signing_responses).map( + ([address, encryptedResponseBase64]) => { + const encryptedResponse = EncryptedThresholdSigningResponse.fromBytes( + fromBase64(encryptedResponseBase64), + ); + return [address, encryptedResponse]; + }, + ); + + const encryptedResponses: Record< + string, + EncryptedThresholdSigningResponse + > = Object.fromEntries(signingResponses); + + return { encryptedResponses, errors }; } } diff --git a/packages/taco/src/sign.ts b/packages/taco/src/sign.ts index b22c03c0b..7d6af045f 100644 --- a/packages/taco/src/sign.ts +++ b/packages/taco/src/sign.ts @@ -1,3 +1,11 @@ +import { + EncryptedThresholdSigningRequest, + EncryptedThresholdSigningResponse, + SessionSharedSecret, + SessionStaticSecret, + ThresholdSigningRequest, + ThresholdSigningResponse, +} from '@nucypher/nucypher-core'; import { convertUserOperationToPython, Domain, @@ -6,14 +14,12 @@ import { PorterClient, SigningCoordinatorAgent, TacoSignature, - TacoSignResult, - toBase64, toHexString, UserOperation, - UserOperationSignatureRequest, } from '@nucypher/shared'; import { ethers } from 'ethers'; +import { CompoundCondition } from './conditions/compound-condition'; import { Condition } from './conditions/condition'; import { ConditionExpression } from './conditions/condition-expr'; import { ConditionContext } from './conditions/context'; @@ -28,6 +34,10 @@ const ERR_MISMATCHED_HASHES = ( `Threshold of signatures not met; multiple mismatched hashes found: ${JSON.stringify( Object.fromEntries(hashToSignatures.entries()), )}`; +const ERR_COHORT_ID_MISMATCH = ( + expectedCohortId: number, + cohortIds: number[], +) => `Cohort id mismatch. Expected ${expectedCohortId}, got ${cohortIds}`; export type SignResult = { messageHash: string; @@ -35,6 +45,111 @@ export type SignResult = { signingResults: { [ursulaAddress: string]: TacoSignature }; }; +/** + * Creates encrypted signing requests for each signer in the cohort. + * Mirrors the pattern from makeDecryptionRequests in tdec.ts + * + * @param cohortId - The signing cohort ID + * @param chainId - The blockchain chain ID + * @param conditionContext - The condition context for evaluation + * @param signers - Array of signers with their static keys + * @param userOp - The user operation to sign + * @param aaVersion - The account abstraction version + * @returns Shared secrets and encrypted requests for each signer + */ +const makeSigningRequests = async ( + cohortId: number, + chainId: number, + conditionContext: ConditionContext, + signers: Awaited>, + userOp: UserOperation, + aaVersion: string, +): Promise<{ + sharedSecrets: Record; + encryptedRequests: Record; +}> => { + const coreContext = await conditionContext.toCoreContext(); + const pythonUserOp = convertUserOperationToPython(userOp); + + const signingRequest = new ThresholdSigningRequest( + cohortId, + chainId, + pythonUserOp, + aaVersion, + coreContext, + 'userop', + ); + + // Generate ephemeral session key for this request + const ephemeralSessionKey = makeSessionKey(); + + // Compute shared secrets for each signer using ECDH + const sharedSecrets: Record = Object.fromEntries( + signers.map(({ provider, signingRequestStaticKey }) => { + const sharedSecret = ephemeralSessionKey.deriveSharedSecret( + signingRequestStaticKey, + ); + return [provider, sharedSecret]; + }), + ); + + // Create encrypted requests for each signer + const encryptedRequests: Record = + Object.fromEntries( + Object.entries(sharedSecrets).map(([provider, sessionSharedSecret]) => { + const encryptedRequest = signingRequest.encrypt( + sessionSharedSecret, + ephemeralSessionKey.publicKey(), + ); + return [provider, encryptedRequest]; + }), + ); + + return { sharedSecrets, encryptedRequests }; +}; + +/** + * Decrypts signing responses from signers. + * Mirrors the pattern from makeDecryptionShares in tdec.ts + * + * @param encryptedResponses - Encrypted responses from signers + * @param sessionSharedSecrets - Shared secrets for decryption + * @param expectedCohortId - Expected cohort ID for validation + * @returns Decrypted signatures by provider address + */ +const decryptSigningResponses = ( + encryptedResponses: Record, + sessionSharedSecrets: Record, + expectedCohortId: number, +): Record => { + const decryptedResponses: Array<[string, ThresholdSigningResponse]> = + Object.entries(encryptedResponses).map(([provider, response]) => { + const decrypted = response.decrypt(sessionSharedSecrets[provider]); + return [provider, decrypted]; + }); + + // Validate cohort IDs match + const cohortIds = decryptedResponses.map(([_, resp]) => resp.cohortId); + if (cohortIds.some((cohortId) => cohortId !== expectedCohortId)) { + throw new Error(ERR_COHORT_ID_MISMATCH(expectedCohortId, cohortIds)); + } + + // Convert to TacoSignature format + return Object.fromEntries( + decryptedResponses.map(([provider, resp]) => [ + provider, + { + messageHash: resp.messageHash, + signature: resp.signature, + signerAddress: resp.signerAddress, + }, + ]), + ); +}; + +// Moving to a separate function to make it easier to mock +const makeSessionKey = () => SessionStaticSecret.random(); + function aggregateSignatures( signaturesByAddress: { [checksumAddress: string]: TacoSignature; @@ -54,7 +169,11 @@ function aggregateSignatures( } /** - * Signs a UserOperation. + * Signs a UserOperation using encrypted signing requests. + * + * This function implements end-to-end encryption for signing requests, + * mirroring the pattern used for decryption requests in tdec.ts. + * * @param provider - The Ethereum provider to use for signing. * @param domain - The TACo domain being used. * @param cohortId - The cohort ID that identifies the signing cohort. @@ -93,37 +212,49 @@ export async function signUserOp( cohortId, ); - const pythonUserOp = convertUserOperationToPython(userOp); + // Create condition context if not provided + const conditionContext = + context || new ConditionContext(new CompoundCondition({})); - const signingRequest = new UserOperationSignatureRequest( - pythonUserOp, - aaVersion, + // Encrypt signing requests + const { sharedSecrets, encryptedRequests } = await makeSigningRequests( cohortId, chainId, - context || {}, - 'userop', + conditionContext, + signers, + userOp, + aaVersion, ); - const signingRequests: Record = Object.fromEntries( - signers.map((signer) => [ - signer.provider, - toBase64(signingRequest.toBytes()), - ]), + // Send encrypted requests to Porter + const { encryptedResponses, errors } = await porter.signUserOp( + encryptedRequests, + threshold, ); - // Build signing request for the user operation - const porterSignResult: TacoSignResult = await porter.signUserOp( - signingRequests, - threshold, + if (Object.keys(encryptedResponses).length < threshold) { + throw new Error(ERR_INSUFFICIENT_SIGNATURES(errors)); + } + + // Decrypt responses + const decryptedSignatures = decryptSigningResponses( + encryptedResponses, + sharedSecrets, + cohortId, ); + const porterSignResult = { + signingResults: decryptedSignatures, + errors, + }; + const hashToSignatures: Map< string, { [ursulaAddress: string]: TacoSignature } > = new Map(); // Single pass: decode signatures and populate signingResults - for (const [ursulaAddress, signature] of Object.entries( + for (const [ursulaAddress, signature] of Object.entries( porterSignResult.signingResults, )) { // For non-optimistic: track hashes and group signatures for aggregation diff --git a/packages/taco/test/taco-sign.test.ts b/packages/taco/test/taco-sign.test.ts index 824ef1cad..be574b96f 100644 --- a/packages/taco/test/taco-sign.test.ts +++ b/packages/taco/test/taco-sign.test.ts @@ -1,3 +1,4 @@ +import { SessionStaticKey } from '@nucypher/nucypher-core'; import { convertUserOperationToPython, PorterClient, @@ -24,9 +25,20 @@ describe('TACo Signing', () => { vi.spyOn(PorterClient.prototype, 'signUserOp').mockImplementation( porterSignUserOpMock, ); + const mockKey = SessionStaticKey.fromBytes(new Uint8Array(32).fill(0)); vi.spyOn(SigningCoordinatorAgent, 'getParticipants').mockResolvedValue([ - { operator: '0xsnr1', provider: '0xnode1', signature: '0xa' }, - { operator: '0xsnr2', provider: '0xnode2', signature: '0xb' }, + { + operator: '0xsnr1', + provider: '0xnode1', + signature: '0xa', + signingRequestStaticKey: mockKey, + }, + { + operator: '0xsnr2', + provider: '0xnode2', + signature: '0xb', + signingRequestStaticKey: mockKey, + }, ]); vi.spyOn(SigningCoordinatorAgent, 'getThreshold').mockResolvedValue(2); }); @@ -214,10 +226,26 @@ describe('TACo Signing', () => { it('should handle insufficient matched hashes in Porter response', async () => { // set up 3 signers - it matters based on how mismatched hashes are handled + const mockKey = SessionStaticKey.fromBytes(new Uint8Array(32).fill(0)); vi.spyOn(SigningCoordinatorAgent, 'getParticipants').mockResolvedValue([ - { operator: '0xsnr1', provider: '0xnode1', signature: '0xa' }, - { operator: '0xsnr2', provider: '0xnode2', signature: '0xb' }, - { operator: '0xsnr3', provider: '0xnode3', signature: '0xc' }, + { + operator: '0xsnr1', + provider: '0xnode1', + signature: '0xa', + signingRequestStaticKey: mockKey, + }, + { + operator: '0xsnr2', + provider: '0xnode2', + signature: '0xb', + signingRequestStaticKey: mockKey, + }, + { + operator: '0xsnr3', + provider: '0xnode3', + signature: '0xc', + signingRequestStaticKey: mockKey, + }, ]); const signingResults = {