diff --git a/src/cli.ts b/src/cli.ts index 2313dd7..9c179f1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { dataSetCommand } from './commands/data-set.js' import { importCommand } from './commands/import.js' import { paymentsCommand } from './commands/payments.js' import { serverCommand } from './commands/server.js' +import { sessionCommand } from './commands/session.js' import { checkForUpdate, type UpdateCheckStatus } from './common/version-check.js' import { version as packageVersion } from './core/utils/version.js' @@ -24,6 +25,7 @@ program.addCommand(paymentsCommand) program.addCommand(dataSetCommand) program.addCommand(importCommand) program.addCommand(addCommand) +program.addCommand(sessionCommand) // Default action - show help if no command specified program.action(() => { diff --git a/src/commands/session.ts b/src/commands/session.ts new file mode 100644 index 0000000..3e6c11e --- /dev/null +++ b/src/commands/session.ts @@ -0,0 +1,79 @@ +/** + * Session key management commands + * + * This module provides CLI commands for creating and managing session keys + * that allow delegated access to Synapse SDK without exposing the main private key. + */ + +import { RPC_URLS } from '@filoz/synapse-sdk' +import { Command } from 'commander' +import picocolors from 'picocolors' +import { createSessionKey, formatSessionKeyOutput } from '../core/session/create-session-key.js' +import { addAuthOptions } from '../utils/cli-options.js' + +export const sessionCommand = new Command('session').description( + 'Manage session keys for delegated access to Synapse SDK' +) + +/** + * Create command - generates and authorizes a new session key + */ +const createCommand = new Command('create') + .description('Create and authorize a new session key') + .option('--validity-days ', 'Number of days the session key should be valid', '10') + .option('--session-private-key ', 'Private key for the session wallet (can also use SESSION_PRIVATE_KEY env)') + .action(async (options) => { + try { + // Get private key from options or environment + const privateKey = options.privateKey || process.env.PRIVATE_KEY + if (!privateKey) { + console.error(picocolors.red('Error: PRIVATE_KEY environment variable or --private-key option is required')) + process.exit(1) + } + + // Get session private key from options or environment (optional) + const sessionPrivateKey = options.sessionPrivateKey || process.env.SESSION_PRIVATE_KEY + + const validityDays = Number.parseInt(options.validityDays, 10) + if (Number.isNaN(validityDays) || validityDays <= 0) { + console.error(picocolors.red(`Error: Invalid validity days: ${options.validityDays}`)) + process.exit(1) + } + + // Ensure we use HTTP RPC URL (JsonRpcProvider doesn't support WebSocket) + // If user provided a custom RPC_URL env var or --rpc-url flag, use it + // Otherwise default to HTTP endpoint + let rpcUrl = options.rpcUrl + if (!rpcUrl || rpcUrl === RPC_URLS.calibration.websocket) { + rpcUrl = RPC_URLS.calibration.http + } + + // Create session key with progress logging + const result = await createSessionKey({ + privateKey, + sessionPrivateKey, + validityDays, + rpcUrl, + warmStorageAddress: options.warmStorageAddress, + onProgress: (step, details) => { + console.log(picocolors.cyan(`${step}`)) + if (details && Object.keys(details).length > 0) { + for (const [key, value] of Object.entries(details)) { + console.log(picocolors.dim(` ${key}: ${value}`)) + } + } + }, + }) + + // Output formatted result + console.log('') + console.log(formatSessionKeyOutput(result)) + } catch (error) { + console.error(picocolors.red('Session key creation failed:'), error instanceof Error ? error.message : error) + process.exit(1) + } + }) + +// Add auth options (for --private-key, --rpc-url) +addAuthOptions(createCommand) +sessionCommand.addCommand(createCommand) diff --git a/src/core/session/create-session-key.ts b/src/core/session/create-session-key.ts new file mode 100644 index 0000000..0a8749b --- /dev/null +++ b/src/core/session/create-session-key.ts @@ -0,0 +1,250 @@ +/** + * Session key creation for delegated access to Synapse SDK + * + * This module provides functionality to create and authorize session keys + * for use with the Synapse SDK, allowing delegated access without exposing + * the main private key. + */ + +import { + ADD_PIECES_TYPEHASH, + CONTRACT_ADDRESSES, + CREATE_DATA_SET_TYPEHASH, + getFilecoinNetworkType, + RPC_URLS, + WarmStorageService, +} from '@filoz/synapse-sdk' +import type { HDNodeWallet } from 'ethers' +import { Contract, JsonRpcProvider, Wallet } from 'ethers' + +/** + * Permission type hashes for the session key registry + * Re-exported from synapse-sdk for convenience + */ +export const PERMISSION_TYPE_HASHES = { + CREATE_DATA_SET: CREATE_DATA_SET_TYPEHASH, + ADD_PIECES: ADD_PIECES_TYPEHASH, +} as const + +export interface SessionKeyResult { + /** + * The newly generated or provided session key wallet + */ + sessionWallet: HDNodeWallet | Wallet + + /** + * The owner wallet used to authorize the session key + */ + ownerWallet: Wallet + + /** + * Unix timestamp when the session key expires + */ + expiry: number + + /** + * Number of days the session key is valid + */ + validityDays: number + + /** + * The registry contract address used + */ + registryAddress: string + + /** + * The RPC URL used + */ + rpcUrl: string +} + +export interface CreateSessionKeyOptions { + /** + * Private key of the wallet that will authorize the session key + */ + privateKey: string + + /** + * Optional private key for the session wallet + * If provided, this will be used instead of generating a random wallet + */ + sessionPrivateKey?: string + + /** + * Number of days the session key should be valid (default: 10) + */ + validityDays?: number + + /** + * RPC URL to use (default: Calibration testnet) + */ + rpcUrl?: string + + /** + * Warm Storage contract address override (optional) + * If not provided, uses the default for the detected network + */ + warmStorageAddress?: string + + /** + * Progress callback for logging/UI updates + */ + onProgress?: (step: string, details?: Record) => void +} + +/** + * Creates and authorizes a new session key for use with Synapse SDK + * + * This function: + * 1. Creates a session wallet from provided private key, or generates a new random wallet + * 2. Calculates the expiry timestamp based on validity days + * 3. Calls the registry contract's login() function to authorize the session key + * 4. Returns all relevant information for the user + * + * @param options - Configuration for session key creation + * @returns Session key information including wallets, expiry, and contract details + * + * @example + * ```typescript + * const result = await createSessionKey({ + * privateKey: '0x...', + * sessionPrivateKey: '0x...', // Optional: use existing session key + * validityDays: 30, + * onProgress: (step, details) => console.log(step, details) + * }) + * + * console.log('Session key:', result.sessionWallet.privateKey) + * console.log('Owner address:', result.ownerWallet.address) + * ``` + */ +export async function createSessionKey(options: CreateSessionKeyOptions): Promise { + const { + privateKey, + sessionPrivateKey, + validityDays = 10, + rpcUrl = RPC_URLS.calibration.http, + warmStorageAddress, + onProgress, + } = options + + // Step 1: Create or generate session key + let sessionWallet: HDNodeWallet | Wallet + if (sessionPrivateKey) { + onProgress?.('Using provided session private key...', {}) + sessionWallet = new Wallet(sessionPrivateKey) + onProgress?.('Using provided session key', { + address: sessionWallet.address, + // Only show first 20 chars of private key for security + privateKey: `${sessionWallet.privateKey.slice(0, 20)}...`, + }) + } else { + onProgress?.('Generating new session key...', {}) + sessionWallet = Wallet.createRandom() + onProgress?.('Generated session key', { + address: sessionWallet.address, + // Only show first 20 chars of private key for security + privateKey: `${sessionWallet.privateKey.slice(0, 20)}...`, + }) + } + + // Step 2: Calculate expiry timestamp + onProgress?.('Calculating expiry timestamp...', {}) + const currentTime = Math.floor(Date.now() / 1000) + const expiry = currentTime + validityDays * 24 * 60 * 60 + const expiryDate = new Date(expiry * 1000).toISOString() + onProgress?.('Calculated expiry', { + expiry: expiryDate, + validityDays: String(validityDays), + }) + + // Step 3: Initialize provider and wallet + onProgress?.('Initializing wallet and discovering contract addresses...', {}) + const provider = new JsonRpcProvider(rpcUrl) + const ownerWallet = new Wallet(privateKey, provider) + onProgress?.('Owner wallet initialized', { + address: ownerWallet.address, + }) + + // Step 4: Get the session key registry address from WarmStorage + // Determine the warm storage address to use + const network = await getFilecoinNetworkType(provider) + const resolvedWarmStorageAddress = warmStorageAddress ?? CONTRACT_ADDRESSES.WARM_STORAGE[network] + if (!resolvedWarmStorageAddress) { + throw new Error(`No Warm Storage address configured for network: ${network}`) + } + + const warmStorage = await WarmStorageService.create(provider, resolvedWarmStorageAddress) + const registryAddress = warmStorage.getSessionKeyRegistryAddress() + onProgress?.('Discovered session key registry', { + registry: registryAddress, + network, + }) + + // Step 5: Authorize session key on-chain + onProgress?.('Authorizing session key on-chain (this may take a minute)...', { + registry: registryAddress, + rpcUrl, + }) + + // Use minimal ABI with 3-parameter login function (deployed contract version) + // The full SDK ABI has 4 parameters but the deployed contract uses 3 + const registryAbi = [ + 'function login(address signer, uint256 expiry, bytes32[] permissions) external', + 'function authorizationExpiry(address user, address signer, bytes32 permission) external view returns (uint256)', + ] + const registry = new Contract(registryAddress, registryAbi, ownerWallet) + + // Call login with both permission type hashes + const typeHashes = [PERMISSION_TYPE_HASHES.CREATE_DATA_SET, PERMISSION_TYPE_HASHES.ADD_PIECES] + + // Contract methods are dynamically typed, so we use 'as any' for the call + // login(address signer, uint256 expiry, bytes32[] permissions) + const tx = await (registry as any).login(sessionWallet.address, expiry, typeHashes) + onProgress?.('Transaction submitted', { + txHash: tx.hash, + }) + + const receipt = await tx.wait() + onProgress?.('Transaction confirmed', { + txHash: receipt.hash, + blockNumber: String(receipt.blockNumber), + }) + + return { + sessionWallet, + ownerWallet, + expiry, + validityDays, + registryAddress, + rpcUrl, + } +} + +/** + * Formats session key result for display to the user + * + * @param result - The session key creation result + * @returns Formatted string for console output + */ +export function formatSessionKeyOutput(result: SessionKeyResult): string { + const expiryDate = new Date(result.expiry * 1000).toISOString().replace('T', ' ').split('.')[0] + + return ` +========================================== +Session key created successfully! +========================================== +Validity: ${result.validityDays} days (expires: ${expiryDate}) + +Add these to your .env file: +------------------------------------------ +WALLET_ADDRESS=${result.ownerWallet.address} +SESSION_KEY=${result.sessionWallet.privateKey} + +Session key info (for debugging): +------------------------------------------ +SESSION_KEY_ADDRESS=${result.sessionWallet.address} +OWNER_ADDRESS=${result.ownerWallet.address} +REGISTRY=${result.registryAddress} +EXPIRY=${result.expiry} +`.trim() +}