diff --git a/src/add/add.ts b/src/add/add.ts index 6e8f822..c5fff4f 100644 --- a/src/add/add.ts +++ b/src/add/add.ts @@ -94,14 +94,24 @@ export async function runAdd(options: AddOptions): Promise { // Initialize Synapse SDK (without storage context) spinner.start('Initializing Synapse SDK...') - if (!options.privateKey) { - spinner.stop(`${pc.red('✗')} Private key required via --private-key or PRIVATE_KEY env`) - cancel('Add cancelled') + // Check for session key auth (env vars only for now) + const walletAddress = process.env.WALLET_ADDRESS + const sessionKey = process.env.SESSION_KEY + + // Validate authentication (either standard or session key mode) + const hasStandardAuth = options.privateKey != null + const hasSessionKeyAuth = walletAddress != null && sessionKey != null + + if (!hasStandardAuth && !hasSessionKeyAuth) { + spinner.stop(`${pc.red('✗')} Authentication required`) + cancel('Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars') process.exit(1) } const config = { privateKey: options.privateKey, + walletAddress, + sessionKey, rpcUrl: options.rpcUrl || RPC_URLS.calibration.websocket, // Other config fields not needed for add port: 0, diff --git a/src/core/payments/index.ts b/src/core/payments/index.ts index c347e41..c096ad5 100644 --- a/src/core/payments/index.ts +++ b/src/core/payments/index.ts @@ -131,7 +131,7 @@ export async function checkFILBalance(synapse: Synapse): Promise<{ try { const provider = synapse.getProvider() - const signer = synapse.getSigner() + const signer = synapse.getClient() // owner wallet const address = await signer.getAddress() // Get native token balance diff --git a/src/core/synapse/address-only-signer.ts b/src/core/synapse/address-only-signer.ts new file mode 100644 index 0000000..af11eca --- /dev/null +++ b/src/core/synapse/address-only-signer.ts @@ -0,0 +1,41 @@ +/** + * Address-only signer for session key authentication + * + * This signer provides an address but throws on any signing operations. + * Used as the "owner" signer when authenticating with session keys, + * where the actual signing is done by the session key wallet. + */ + +import { AbstractSigner, type Provider, type TransactionRequest } from 'ethers' + +const cannotSign = (thing: string) => + `Cannot sign ${thing} - this is an address-only signer for session key authentication. Signing operations should be performed by the session key.` + +export class AddressOnlySigner extends AbstractSigner { + readonly address: string + + constructor(address: string, provider?: Provider) { + super(provider) + this.address = address + } + + async getAddress(): Promise { + return this.address + } + + connect(provider: Provider): AddressOnlySigner { + return new AddressOnlySigner(this.address, provider) + } + + async signTransaction(_tx: TransactionRequest): Promise { + throw new Error(cannotSign('transaction')) + } + + async signMessage(_message: string | Uint8Array): Promise { + throw new Error(cannotSign('message')) + } + + async signTypedData(_domain: any, _types: Record, _value: Record): Promise { + throw new Error(cannotSign('typed data')) + } +} diff --git a/src/core/synapse/index.ts b/src/core/synapse/index.ts index 2947931..38f76ea 100644 --- a/src/core/synapse/index.ts +++ b/src/core/synapse/index.ts @@ -1,4 +1,6 @@ import { + ADD_PIECES_TYPEHASH, + CREATE_DATA_SET_TYPEHASH, METADATA_KEYS, type ProviderInfo, RPC_URLS, @@ -8,7 +10,9 @@ import { Synapse, type SynapseOptions, } from '@filoz/synapse-sdk' +import { type Provider as EthersProvider, JsonRpcProvider, Wallet, WebSocketProvider } from 'ethers' import type { Logger } from 'pino' +import { AddressOnlySigner } from './address-only-signer.js' const WEBSOCKET_REGEX = /^ws(s)?:\/\//i @@ -51,13 +55,22 @@ export interface Config { /** * Configuration for Synapse initialization - * Extends the main Config but makes privateKey required and rpcUrl optional + * + * Supports two authentication modes: + * 1. Standard: privateKey only + * 2. Session Key: walletAddress + sessionKey */ -export interface SynapseSetupConfig extends Partial> { - /** Private key used for signing transactions. */ - privateKey: string +export interface SynapseSetupConfig { + /** Private key for standard authentication (mutually exclusive with session key mode) */ + privateKey?: string | undefined + /** Wallet address for session key mode (requires sessionKey) */ + walletAddress?: string | undefined + /** Session key private key (requires walletAddress) */ + sessionKey?: string | undefined /** RPC endpoint for the target Filecoin network. Defaults to calibration. */ rpcUrl?: string | undefined + /** Optional override for WarmStorage contract address */ + warmStorageAddress?: string | undefined } /** @@ -136,89 +149,122 @@ export function resetSynapseService(): void { activeProvider = null } +/** + * Validate authentication configuration + */ +function validateAuthConfig(config: SynapseSetupConfig): 'standard' | 'session-key' { + const hasStandardAuth = config.privateKey != null + const hasSessionKeyAuth = config.walletAddress != null && config.sessionKey != null + + if (!hasStandardAuth && !hasSessionKeyAuth) { + throw new Error('Authentication required: provide either a privateKey or walletAddress + sessionKey') + } + + if (hasStandardAuth && hasSessionKeyAuth) { + throw new Error('Conflicting authentication: provide either a privateKey or walletAddress + sessionKey, not both') + } + + return hasStandardAuth ? 'standard' : 'session-key' +} + +/** + * Create ethers provider for the given RPC URL + */ +function createProvider(rpcURL: string): EthersProvider { + if (WEBSOCKET_REGEX.test(rpcURL)) { + return new WebSocketProvider(rpcURL) + } + return new JsonRpcProvider(rpcURL) +} + +/** + * Setup and verify session key, throws if expired + */ +async function setupSessionKey(synapse: Synapse, sessionWallet: Wallet, logger: Logger): Promise { + const sessionKey = synapse.createSessionKey(sessionWallet) + + // Verify permissions - fail fast if expired or expiring soon + const expiries = await sessionKey.fetchExpiries([CREATE_DATA_SET_TYPEHASH, ADD_PIECES_TYPEHASH]) + const now = Math.floor(Date.now() / 1000) + const bufferTime = 30 * 60 // 30 minutes in seconds + const minValidTime = now + bufferTime + const createExpiry = Number(expiries[CREATE_DATA_SET_TYPEHASH]) + const addExpiry = Number(expiries[ADD_PIECES_TYPEHASH]) + + if (createExpiry <= minValidTime || addExpiry <= minValidTime) { + throw new Error( + `Session key expired or expiring soon (requires 30+ minutes validity). CreateDataSet: ${new Date(createExpiry * 1000).toISOString()}, AddPieces: ${new Date(addExpiry * 1000).toISOString()}` + ) + } + + logger.info({ event: 'synapse.session_key.verified', createExpiry, addExpiry }, 'Session key verified') + + synapse.setSession(sessionKey) + logger.info({ event: 'synapse.session_key.activated' }, 'Session key activated') +} + /** * Initialize the Synapse SDK without creating storage context * - * This function initializes the Synapse SDK connection without creating - * a storage context. This method is primarily a wrapper for handling our - * custom configuration needs and adding detailed logging. + * Supports two authentication modes: + * - Standard: privateKey only + * - Session Key: walletAddress + sessionKey * - * @param config - Application configuration with privateKey and RPC URL + * @param config - Application configuration with authentication credentials * @param logger - Logger instance for detailed operation tracking * @returns Initialized Synapse instance */ export async function initializeSynapse(config: SynapseSetupConfig, logger: Logger): Promise { try { - // Log the configuration status - logger.info( - { - hasPrivateKey: config.privateKey != null, - rpcUrl: config.rpcUrl, - }, - 'Initializing Synapse' - ) + const authMode = validateAuthConfig(config) + const rpcURL = config.rpcUrl ?? RPC_URLS.calibration.websocket - // IMPORTANT: Private key is required for transaction signing - // In production, this should come from secure environment variables, or a wallet integration - const privateKey = config.privateKey - if (privateKey == null) { - const error = new Error('PRIVATE_KEY environment variable is required for Synapse integration') - logger.error( - { - event: 'synapse.init.failed', - error: error.message, - }, - 'Synapse initialization failed: missing PRIVATE_KEY' - ) - throw error - } - - logger.info({ event: 'synapse.init' }, 'Initializing Synapse SDK') - - // Configure Synapse with network settings - // Network options: 314 (mainnet) or 314159 (calibration testnet) - const synapseOptions: SynapseOptions = { - privateKey, - rpcURL: config.rpcUrl ?? RPC_URLS.calibration.websocket, // Default to calibration testnet - } + logger.info({ event: 'synapse.init', authMode, rpcUrl: rpcURL }, 'Initializing Synapse SDK') - // Optional: Override the default Warm Storage contract address - // Useful for testing with custom deployments - if (config.warmStorageAddress != null) { + const synapseOptions: SynapseOptions = { rpcURL } + if (config.warmStorageAddress) { synapseOptions.warmStorageAddress = config.warmStorageAddress } - const synapse = await Synapse.create(synapseOptions) - - // Store reference to the provider for cleanup if it's a WebSocket provider - if (synapseOptions.rpcURL && WEBSOCKET_REGEX.test(synapseOptions.rpcURL)) { + let synapse: Synapse + + if (authMode === 'session-key') { + // Session key mode - validation guarantees these are defined + const walletAddress = config.walletAddress + const sessionKey = config.sessionKey + if (!walletAddress || !sessionKey) { + throw new Error('Internal error: session key config validated but values missing') + } + + // Create provider and signers for session key mode + const provider = createProvider(rpcURL) + activeProvider = provider + + const ownerSigner = new AddressOnlySigner(walletAddress, provider) + const sessionWallet = new Wallet(sessionKey, provider) + + // Initialize with owner signer, then activate session key + synapse = await Synapse.create({ ...synapseOptions, signer: ownerSigner }) + await setupSessionKey(synapse, sessionWallet, logger) + } else { + // Standard mode - validation guarantees privateKey is defined + const privateKey = config.privateKey + if (!privateKey) { + throw new Error('Internal error: standard auth validated but privateKey missing') + } + + synapse = await Synapse.create({ ...synapseOptions, privateKey }) activeProvider = synapse.getProvider() } - // Get network info for logging const network = synapse.getNetwork() - logger.info( - { - event: 'synapse.init', - network, - rpcUrl: synapseOptions.rpcURL, - }, - 'Synapse SDK initialized' - ) + logger.info({ event: 'synapse.init.success', network }, 'Synapse SDK initialized') - // Store instance for cleanup synapseInstance = synapse - return synapse } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - logger.error( - { - event: 'synapse.init.failed', - error: errorMessage, - }, - `Failed to initialize Synapse SDK: ${errorMessage}` - ) + logger.error({ event: 'synapse.init.failed', error: errorMessage }, 'Failed to initialize Synapse SDK') throw error } } diff --git a/src/core/unixfs/browser-car-builder.ts b/src/core/unixfs/browser-car-builder.ts index 082feb2..1009058 100644 --- a/src/core/unixfs/browser-car-builder.ts +++ b/src/core/unixfs/browser-car-builder.ts @@ -6,7 +6,7 @@ */ import { unixfs } from '@helia/unixfs' -import { CarWriter } from '@ipld/car' +import { CarReader, CarWriter } from '@ipld/car' import { CID } from 'multiformats/cid' import { CARWritingBlockstore } from '../car/browser-car-blockstore.js' @@ -251,9 +251,6 @@ async function updateRootCidInCar(carBytes: Uint8Array, rootCid: CID): Promise): Logger { +export function createLogger(config: { logLevel?: string }): Logger { return pino({ level: config.logLevel ?? 'info', }) diff --git a/src/test/unit/dataset-management.test.ts b/src/test/unit/dataset-management.test.ts index ec7897d..cbbb037 100644 --- a/src/test/unit/dataset-management.test.ts +++ b/src/test/unit/dataset-management.test.ts @@ -25,7 +25,7 @@ describe('Dataset Management', () => { privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001', rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', } - logger = createLogger(config) + logger = createLogger({ logLevel: 'info' }) resetSynapseService() vi.clearAllMocks() }) diff --git a/src/test/unit/import.test.ts b/src/test/unit/import.test.ts index 310271a..8f6c2e3 100644 --- a/src/test/unit/import.test.ts +++ b/src/test/unit/import.test.ts @@ -364,7 +364,9 @@ describe('CAR Import', () => { } await expect(runCarImport(options)).rejects.toThrow('process.exit called') - expect(consoleMocks.error).toHaveBeenCalledWith('Import cancelled') + expect(consoleMocks.error).toHaveBeenCalledWith( + 'Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars' + ) }) it('should use custom RPC URL if provided', async () => { diff --git a/src/test/unit/payments-setup.test.ts b/src/test/unit/payments-setup.test.ts index 3e76c30..2fc0eb8 100644 --- a/src/test/unit/payments-setup.test.ts +++ b/src/test/unit/payments-setup.test.ts @@ -74,6 +74,7 @@ describe('Payment Setup Tests', () => { mockSynapse = { getProvider: vi.fn().mockReturnValue(mockProvider), getSigner: vi.fn().mockReturnValue(mockSigner), + getClient: vi.fn().mockReturnValue(mockSigner), getNetwork: vi.fn().mockReturnValue('calibration'), getPaymentsAddress: vi.fn().mockReturnValue('0xpayments'), getWarmStorageAddress: vi.fn().mockReturnValue('0xwarmstorage'), diff --git a/src/test/unit/synapse-service.test.ts b/src/test/unit/synapse-service.test.ts index 624a320..fc70d78 100644 --- a/src/test/unit/synapse-service.test.ts +++ b/src/test/unit/synapse-service.test.ts @@ -29,7 +29,7 @@ describe('synapse-service', () => { privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001', // Fake test key rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', } - logger = createLogger(config) + logger = createLogger({ logLevel: 'info' }) // Reset the service instances resetSynapseService() @@ -41,11 +41,10 @@ describe('synapse-service', () => { }) describe('setupSynapse', () => { - it('should throw error when private key is not configured', async () => { - // @ts-expect-error - private key is required + it('should throw error when no authentication is provided', async () => { config.privateKey = undefined - await expect(setupSynapse(config, logger)).rejects.toThrow('PRIVATE_KEY environment variable is required') + await expect(setupSynapse(config, logger)).rejects.toThrow('Authentication required') }) it('should initialize Synapse when private key is configured', async () => { @@ -64,15 +63,16 @@ describe('synapse-service', () => { // Check that initialization logs were called expect(infoSpy).toHaveBeenCalledWith( expect.objectContaining({ - hasPrivateKey: true, + event: 'synapse.init', + authMode: 'standard', rpcUrl: config.rpcUrl, }), - 'Initializing Synapse' + 'Initializing Synapse SDK' ) expect(infoSpy).toHaveBeenCalledWith( - expect.objectContaining({ event: 'synapse.init' }), - 'Initializing Synapse SDK' + expect.objectContaining({ event: 'synapse.init.success' }), + 'Synapse SDK initialized' ) }) @@ -191,11 +191,6 @@ describe('synapse-service', () => { const mockConfig: SynapseSetupConfig = { privateKey: 'test-private-key', rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', - port: 3000, - host: '127.0.0.1', - databasePath: ':memory:', - carStoragePath: './cars', - logLevel: 'info', } const service = await setupSynapse(mockConfig, logger) @@ -212,11 +207,6 @@ describe('synapse-service', () => { const mockConfig: SynapseSetupConfig = { privateKey: 'test-private-key', rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', - port: 3000, - host: '127.0.0.1', - databasePath: ':memory:', - carStoragePath: './cars', - logLevel: 'info', } const service = await setupSynapse(mockConfig, logger) @@ -239,11 +229,6 @@ describe('synapse-service', () => { const mockConfig: SynapseSetupConfig = { privateKey: 'test-private-key', rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', - port: 3000, - host: '127.0.0.1', - databasePath: ':memory:', - carStoragePath: './cars', - logLevel: 'info', } const service = await setupSynapse(mockConfig, logger) @@ -264,11 +249,6 @@ describe('synapse-service', () => { const mockConfig: SynapseSetupConfig = { privateKey: 'test-private-key', rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', - port: 3000, - host: '127.0.0.1', - databasePath: ':memory:', - carStoragePath: './cars', - logLevel: 'info', } const service = await setupSynapse(mockConfig, logger) diff --git a/upload-action/src/build.js b/upload-action/src/build.js index 30a08ee..d76517c 100644 --- a/upload-action/src/build.js +++ b/upload-action/src/build.js @@ -2,6 +2,7 @@ import pc from 'picocolors' import pino from 'pino' import { createCarFile } from './filecoin.js' import { readEventPayload, updateCheck } from './github.js' +import { parseInputs, resolveContentPath } from './inputs.js' import { formatSize } from './outputs.js' /** @@ -49,7 +50,6 @@ export async function runBuild() { console.log('::notice::Building CAR file but upload will be blocked') } - const { parseInputs, resolveContentPath } = await import('./inputs.js') const inputs = /** @type {ParsedInputs} */ (parseInputs('compute')) const { contentPath } = inputs const targetPath = resolveContentPath(contentPath)