Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions src/common/crypto/sweep-signer.util.ts
Original file line number Diff line number Diff line change
@@ -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());
}
1 change: 1 addition & 0 deletions src/config/stellar.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}));
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface AuthorizeSweepParams {
ephemeralPublicKey: string;
destinationAddress: string;
nonce?: bigint;
}
186 changes: 45 additions & 141 deletions src/modules/sweeps/providers/contract.provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -40,6 +41,12 @@ const mockAddress = {
toScVal: jest.fn<xdr.ScVal, []>(),
};

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<typeof import('@stellar/stellar-sdk')>(
'@stellar/stellar-sdk',
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(),
);
});

Expand All @@ -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<string, string> = new Map();
jest.spyOn(xdr.ScVal, 'scvBytes').mockImplementation(() => {
Expand Down
Loading