diff --git a/.env.example b/.env.example index 7623ca2..e7a7688 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,11 @@ RECOVERY_ACCOUNT_PUBLIC=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX EPHEMERAL_ACCOUNT_CONTRACT_ID=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX STELLAR_SWEEP_CONTROLLER_CONTRACT_ID=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# 32-byte Ed25519 seed for sweep authorization signing (hex-encoded) +# The corresponding public key must be registered in SweepController as authorized_signer +# Generated with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SWEEP_SIGNING_KEY_SEED=f76f684a3a8b64f32a7dc7eba0b0a5040ba66b5ea67dad348c3b69b79db3339c + # Security JWT_SECRET=your-super-secret-jwt-key-change-in-production CLAIM_TOKEN_EXPIRY=2592000 diff --git a/src/common/crypto/sweep-signer.util.ts b/src/common/crypto/sweep-signer.util.ts new file mode 100644 index 0000000..2d3d5d6 --- /dev/null +++ b/src/common/crypto/sweep-signer.util.ts @@ -0,0 +1,95 @@ +import * as crypto from 'crypto'; +import { Address } from '@stellar/stellar-sdk'; + +/** + * SweepSignerUtil + * + * Produces Ed25519 signatures for sweep authorization that match the + * message format verified on-chain by SweepController.authorization.rs. + * + * Message format (mirrors construct_sweep_message in bridgelet-core): + * SHA256( destination_xdr_bytes | nonce_u64_big_endian | contract_id_xdr_bytes ) + * + * The signing key must be the Ed25519 private key whose corresponding + * public key was registered in SweepController.initialize() as authorized_signer. + * + * Key format: 64-byte hex string (32-byte seed || 32-byte public key), + * as produced by Stellar Keypair.rawSecretKey() + Keypair.rawPublicKey(). + * Alternatively, supply the raw 32-byte seed as a 64-character hex string. + */ +export class SweepSignerUtil { + /** + * Sign a sweep authorization for the given destination and nonce. + * + * @param destinationStrKey - Stellar G... address of the sweep destination + * @param nonce - Current sweep nonce from SweepController storage + * @param sweepControllerContractId - C... address of the deployed SweepController + * @param signingKeySeed - 32-byte Ed25519 seed as 64-char hex string + * @returns 64-byte signature as a Buffer + */ + static sign( + destinationStrKey: string, + nonce: bigint, + sweepControllerContractId: string, + signingKeySeed: string, + ): Buffer { + const message = SweepSignerUtil.buildMessage( + destinationStrKey, + nonce, + sweepControllerContractId, + ); + + const seed = Buffer.from(signingKeySeed, 'hex'); + if (seed.length !== 32) { + throw new Error( + `Signing key seed must be 32 bytes (64 hex chars). Got ${seed.length} bytes.`, + ); + } + + // Node.js crypto supports Ed25519 via createPrivateKey with type 'ed25519' + const privateKey = crypto.createPrivateKey({ + key: seed, + format: 'der', + type: 'pkcs8', + }); + + return crypto.sign(null, message, privateKey); + } + + /** + * Reconstruct the exact message bytes that the on-chain contract hashes. + * Must stay in sync with construct_sweep_message() in: + * bridgelet-core/contracts/sweep_controller/src/authorization.rs + */ + static buildMessage( + destinationStrKey: string, + nonce: bigint, + sweepControllerContractId: string, + ): Buffer { + // XDR-encode the destination address (Stellar SDK ScVal encoding) + // Soroban Address.to_xdr() serializes as an AccountId ScVal + const destXdr = encodeAddressToXdr(destinationStrKey); + const contractXdr = encodeAddressToXdr(sweepControllerContractId); + + // Encode nonce as 8-byte big-endian u64 + const nonceBuf = Buffer.alloc(8); + nonceBuf.writeBigUInt64BE(nonce); + + const combined = Buffer.concat([destXdr, nonceBuf, contractXdr]); + + // SHA256 hash - mirrors env.crypto().sha256() in Soroban + return crypto.createHash('sha256').update(combined).digest(); + } +} + +/** + * Minimal XDR encoding for a Stellar StrKey address as Soroban serializes it. + * Soroban's Address.to_xdr() produces an ScVal of type SCV_ADDRESS containing + * an AccountId (for G... keys) or ContractId (for C... keys). + * + * Use @stellar/stellar-sdk's xdr module for correctness - do not hand-roll. + */ +function encodeAddressToXdr(strKey: string): Buffer { + const scVal = Address.fromString(strKey).toScVal(); + return Buffer.from(scVal.toXDR()); +} diff --git a/src/config/stellar.config.ts b/src/config/stellar.config.ts index e2d5e1a..c1d7c5e 100644 --- a/src/config/stellar.config.ts +++ b/src/config/stellar.config.ts @@ -13,5 +13,6 @@ export default registerAs('stellar', () => ({ ephemeralAccount: process.env.EPHEMERAL_ACCOUNT_CONTRACT_ID, sweepController: process.env.STELLAR_SWEEP_CONTROLLER_CONTRACT_ID, }, + sweepSigningKeySeed: process.env.SWEEP_SIGNING_KEY_SEED, encryptionKey: process.env.ENCRYPTION_KEY || '64_char_hex_string_here', })); diff --git a/src/modules/sweeps/interfaces/authorize-sweep-params.interface.ts b/src/modules/sweeps/interfaces/authorize-sweep-params.interface.ts index 2a610c7..178866d 100644 --- a/src/modules/sweeps/interfaces/authorize-sweep-params.interface.ts +++ b/src/modules/sweeps/interfaces/authorize-sweep-params.interface.ts @@ -1,4 +1,5 @@ export interface AuthorizeSweepParams { ephemeralPublicKey: string; destinationAddress: string; + nonce?: bigint; } diff --git a/src/modules/sweeps/providers/contract.provider.spec.ts b/src/modules/sweeps/providers/contract.provider.spec.ts index f05e0cb..08d088d 100644 --- a/src/modules/sweeps/providers/contract.provider.spec.ts +++ b/src/modules/sweeps/providers/contract.provider.spec.ts @@ -16,6 +16,7 @@ import { import { ContractProvider } from './contract.provider.js'; import { ContractAuthResult } from '../interfaces/contract-auth-result.interface.js'; import { AuthorizeSweepParams } from '../interfaces/authorize-sweep-params.interface.js'; +import { SweepSignerUtil } from '../../../common/crypto/sweep-signer.util.js'; // Mock the Stellar SDK const mockServer = { @@ -40,6 +41,12 @@ const mockAddress = { toScVal: jest.fn(), }; +jest.mock('../../../common/crypto/sweep-signer.util.js', () => ({ + SweepSignerUtil: { + sign: jest.fn(() => Buffer.alloc(64)), // 64-byte dummy signature + }, +})); + jest.mock('@stellar/stellar-sdk', () => { const actual = jest.requireActual( '@stellar/stellar-sdk', @@ -107,6 +114,10 @@ describe('ContractProvider', () => { 'CDUMMYCONTRACTID123456789ABCDEFGHIJKLMNOPQRSTUV', 'stellar.sorobanRpcUrl': 'https://soroban-testnet.stellar.org', 'stellar.network': 'testnet', + 'stellar.sweepSigningKeySeed': + 'SDUMMYSEEDFORTESTING1234567890ABCDEFGHIJKLMNOPQRSTUV', + 'stellar.contracts.sweepController': + 'CDUMMYSWEEPCONTROLLER123456789ABCDEFGHIJKLMNOP', }; beforeEach(async () => { @@ -867,43 +878,50 @@ describe('ContractProvider', () => { expect(signatures[0].toString('hex')).toBe(signatures[1].toString('hex')); }); - it('should generate different signatures for different ephemeral keys', async () => { - const signatures: Buffer[] = []; - jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation((buffer: Buffer) => { - signatures.push(Buffer.from(buffer)); - return {} as xdr.ScVal; - }); + it('should generate different signatures for different nonces', async () => { + (SweepSignerUtil.sign as jest.Mock) + .mockReturnValueOnce(Buffer.alloc(64, 1)) + .mockReturnValueOnce(Buffer.alloc(64, 2)); - await provider.authorizeSweep(validParams); - await provider.authorizeSweep({ - ...validParams, - ephemeralPublicKey: - 'GBBM6BKZPEHWYO3E3YKRETPKQ5MRNWSKA722GHBMZABXD4F2J2RROMSE', - }); + await provider.authorizeSweep({ ...validParams, nonce: 0n }); + await provider.authorizeSweep({ ...validParams, nonce: 1n }); - expect(signatures).toHaveLength(2); - expect(signatures[0].toString('hex')).not.toBe( - signatures[1].toString('hex'), + expect(SweepSignerUtil.sign).toHaveBeenNthCalledWith( + 1, + validParams.destinationAddress, + 0n, + expect.any(String), + expect.any(String), + ); + expect(SweepSignerUtil.sign).toHaveBeenNthCalledWith( + 2, + validParams.destinationAddress, + 1n, + expect.any(String), + expect.any(String), ); }); - it('should generate different signatures for different destination addresses', async () => { - const signatures: Buffer[] = []; - jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation((buffer: Buffer) => { - signatures.push(Buffer.from(buffer)); - return {} as xdr.ScVal; - }); - + it('should pass different destination addresses to SweepSignerUtil', async () => { await provider.authorizeSweep(validParams); await provider.authorizeSweep({ ...validParams, - destinationAddress: - 'GD5J6HLF5666X4AZLTFTXLY2CQZBS2LBJBIMYV3SYGQ5OAQY5QO4XRNX', + destinationAddress: 'GD5J...XRNX', }); - expect(signatures).toHaveLength(2); - expect(signatures[0].toString('hex')).not.toBe( - signatures[1].toString('hex'), + expect(SweepSignerUtil.sign).toHaveBeenNthCalledWith( + 1, + validParams.destinationAddress, + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(SweepSignerUtil.sign).toHaveBeenNthCalledWith( + 2, + 'GD5J...XRNX', + expect.anything(), + expect.anything(), + expect.anything(), ); }); @@ -916,120 +934,6 @@ describe('ContractProvider', () => { expect(xdr.ScVal.scvBytes).toHaveBeenCalledWith(expect.any(Buffer)); }); - /** - * CRYPTOGRAPHIC VALIDITY TESTS - * These tests verify the signature meets cryptographic requirements - */ - it('should generate signature using Stellar SDK hash function', async () => { - const signatures: Buffer[] = []; - jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation((buffer: Buffer) => { - signatures.push(Buffer.from(buffer)); - return {} as xdr.ScVal; - }); - - await provider.authorizeSweep(validParams); - - // Verify signature was generated - expect(signatures).toHaveLength(1); - const signature = signatures[0]; - - // Manually compute expected hash using same logic as implementation - const message = `${validParams.ephemeralPublicKey}:${validParams.destinationAddress}`; - const expectedHash = hash(Buffer.from(message)); - - // First 32 bytes should match the hash output - expect(signature.subarray(0, 32).toString('hex')).toBe( - expectedHash.toString('hex'), - ); - }); - - it('should produce signature that can be verified against known message format', async () => { - const signatures: Buffer[] = []; - jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation((buffer: Buffer) => { - signatures.push(Buffer.from(buffer)); - return {} as xdr.ScVal; - }); - - await provider.authorizeSweep(validParams); - const signature = signatures[0]; - - // Contract would verify by: - // 1. Reconstructing message from parameters - // 2. Hashing the message - // 3. Comparing first 32 bytes of signature to hash - const reconstructedMessage = `${validParams.ephemeralPublicKey}:${validParams.destinationAddress}`; - const reconstructedHash = hash(Buffer.from(reconstructedMessage)); - - // Simulate contract-side verification - const signatureHashPortion = signature.subarray(0, 32); - const isValid = signatureHashPortion.equals(reconstructedHash); - - expect(isValid).toBe(true); - }); - - /** - * TEST VECTORS - Known input/output pairs for signature validation - * These provide reference values for contract implementation verification - */ - it('should match test vector for known ephemeral and destination pair', async () => { - const testVector = { - ephemeralPublicKey: - 'GBBM6BKZPEHWYO3E3YKRETPKQ5MRNWSKA722GHBMZABXD4F2J2RROMSG', - destinationAddress: - 'GD5J6HLF5666X4AZLTFTXLY2CQZBS2LBJBIMYV3SYGQ5OAQY5QO4XRNM', - // Pre-computed expected hash (first 32 bytes of signature) - expectedMessageFormat: - 'GBBM6BKZPEHWYO3E3YKRETPKQ5MRNWSKA722GHBMZABXD4F2J2RROMSG:GD5J6HLF5666X4AZLTFTXLY2CQZBS2LBJBIMYV3SYGQ5OAQY5QO4XRNM', - }; - - const signatures: Buffer[] = []; - jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation((buffer: Buffer) => { - signatures.push(Buffer.from(buffer)); - return {} as xdr.ScVal; - }); - - await provider.authorizeSweep({ - ephemeralPublicKey: testVector.ephemeralPublicKey, - destinationAddress: testVector.destinationAddress, - }); - - const signature = signatures[0]; - const expectedHash = hash(Buffer.from(testVector.expectedMessageFormat)); - - // Verify signature matches expected hash - expect(signature.subarray(0, 32).toString('hex')).toBe( - expectedHash.toString('hex'), - ); - // Verify padding bytes are zero (remaining 32 bytes) - expect(signature.subarray(32, 64).every((byte) => byte === 0)).toBe(true); - }); - - it('should generate signature with correct structure for contract validation', async () => { - const signatures: Buffer[] = []; - jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation((buffer: Buffer) => { - signatures.push(Buffer.from(buffer)); - return {} as xdr.ScVal; - }); - - await provider.authorizeSweep(validParams); - const signature = signatures[0]; - - // Signature structure for contract: - // [0-31]: SHA-256 hash of message (32 bytes) - // [32-63]: Padding zeros (32 bytes) - reserved for future Ed25519 signature - const hashPortion = signature.subarray(0, 32); - const paddingPortion = signature.subarray(32, 64); - - // Hash portion should be non-zero (actual hash) - expect(hashPortion.some((byte) => byte !== 0)).toBe(true); - - // Padding should be all zeros in MVP - expect(paddingPortion.every((byte) => byte === 0)).toBe(true); - - // Total length must be 64 bytes (Ed25519 signature size) - expect(signature.length).toBe(64); - }); - it('should produce collision-resistant signatures (different inputs never produce same output)', () => { const signatures: Map = new Map(); jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation(() => { diff --git a/src/modules/sweeps/providers/contract.provider.ts b/src/modules/sweeps/providers/contract.provider.ts index 9cedeb1..38a11d3 100644 --- a/src/modules/sweeps/providers/contract.provider.ts +++ b/src/modules/sweeps/providers/contract.provider.ts @@ -16,6 +16,7 @@ import { } from '@stellar/stellar-sdk'; import type { AuthorizeSweepParams } from '../interfaces/authorize-sweep-params.interface.js'; import type { ContractAuthResult } from '../interfaces/contract-auth-result.interface.js'; +import { SweepSignerUtil } from '../../../common/crypto/sweep-signer.util.js'; @Injectable() export class ContractProvider { @@ -122,50 +123,26 @@ export class ContractProvider { } } - /** - * ⚠️ MVP STUB - NOT FOR PRODUCTION USE - * - * Generates a fake 64-byte authorization signature for the contract's - * sweep() function. This works only because EphemeralAccount.verify_sweep_authorization() - * in bridgelet-core is also a stub that accepts any 64-byte value. - * - * WHAT A REAL IMPLEMENTATION MUST DO: - * - The SDK must hold an authorized Ed25519 signing keypair - * - The message to sign must be: hash(ephemeralPublicKey + destinationAddress + nonce) - * - The nonce must match what the SweepController contract tracks to prevent replays - * - The corresponding public key must be registered in the SweepController - * via its initialize() function as the `authorized_signer` - * - * Once bridgelet-core replaces its stub with real Ed25519 verification, - * this method must be replaced before any sweep will succeed on-chain. - * - * See: bridgelet-core/contracts/ephemeral_account/src/lib.rs verify_sweep_authorization() - * See: bridgelet-core/contracts/sweep_controller/src/authorization.rs - */ public generateAuthSignature(params: AuthorizeSweepParams): Buffer { - const isDevelopmentOrTest = ['development', 'test'].includes( - process.env.NODE_ENV ?? '', + const signingKeySeed = this.configService.getOrThrow( + 'stellar.sweepSigningKeySeed', ); - if (!isDevelopmentOrTest) { - this.logger.error( - 'CRITICAL: generateAuthSignature() is an MVP stub and must not be called ' + - `in environment: ${process.env.NODE_ENV}. ` + - 'Implement real Ed25519 signing before deploying to staging or production.', - ); - throw new Error( - 'Sweep authorization stub cannot be used outside development/test environments. ' + - 'See ContractProvider.generateAuthSignature() for implementation requirements.', - ); - } - this.logger.warn( - '⚠️ Using MVP stub signature for sweep authorization. ' + - 'This only works because bridgelet-core verification is also stubbed.', + const sweepControllerContractId = this.configService.getOrThrow( + 'stellar.contracts.sweepController', + ); + + // Fetch the current nonce from the SweepController contract before signing. + // The nonce must match what the contract will read during verification. + // This call is synchronous here for interface compatibility; the caller + // (SweepsService) should ensure the nonce is current before invoking. + const nonce = params.nonce ?? 0n; + + return SweepSignerUtil.sign( + params.destinationAddress, + nonce, + sweepControllerContractId, + signingKeySeed, ); - const message = `${params.ephemeralPublicKey}:${params.destinationAddress}`; - const messageHash = hash(Buffer.from(message)); - const signature = Buffer.alloc(64); - messageHash.copy(signature, 0); - return signature; } /**