Skip to content

Commit 8ef8261

Browse files
authored
feat: add session key authentication support (#103)
* feat: add session key authentication support Adds session key mode as an alternative to private key authentication, allowing delegated authorisation without wallet prompts. Session keys are verified to have 30+ minutes remaining validity at initialization. The owner wallet's private key isn't required for CreateDataSet or AddPieces operations in this mode, so we only require the owner address. - add AddressOnlySigner for read-only owner wallet access - add WALLET_ADDRESS and SESSION_KEY env var support to add/import commands - fix FIL balance check to use owner wallet instead of session key NOTE: This will fail if the owner wallet doesn't meet the payments setup requirements, there's no ability to auto-fix funding or authorisation setup without being able to sign with that wallet. * chore: clean up typing
1 parent dbf14be commit 8ef8261

File tree

12 files changed

+194
-108
lines changed

12 files changed

+194
-108
lines changed

src/add/add.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,24 @@ export async function runAdd(options: AddOptions): Promise<AddResult> {
9494
// Initialize Synapse SDK (without storage context)
9595
spinner.start('Initializing Synapse SDK...')
9696

97-
if (!options.privateKey) {
98-
spinner.stop(`${pc.red('✗')} Private key required via --private-key or PRIVATE_KEY env`)
99-
cancel('Add cancelled')
97+
// Check for session key auth (env vars only for now)
98+
const walletAddress = process.env.WALLET_ADDRESS
99+
const sessionKey = process.env.SESSION_KEY
100+
101+
// Validate authentication (either standard or session key mode)
102+
const hasStandardAuth = options.privateKey != null
103+
const hasSessionKeyAuth = walletAddress != null && sessionKey != null
104+
105+
if (!hasStandardAuth && !hasSessionKeyAuth) {
106+
spinner.stop(`${pc.red('✗')} Authentication required`)
107+
cancel('Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars')
100108
process.exit(1)
101109
}
102110

103111
const config = {
104112
privateKey: options.privateKey,
113+
walletAddress,
114+
sessionKey,
105115
rpcUrl: options.rpcUrl || RPC_URLS.calibration.websocket,
106116
// Other config fields not needed for add
107117
port: 0,

src/core/payments/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export async function checkFILBalance(synapse: Synapse): Promise<{
131131

132132
try {
133133
const provider = synapse.getProvider()
134-
const signer = synapse.getSigner()
134+
const signer = synapse.getClient() // owner wallet
135135
const address = await signer.getAddress()
136136

137137
// Get native token balance
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Address-only signer for session key authentication
3+
*
4+
* This signer provides an address but throws on any signing operations.
5+
* Used as the "owner" signer when authenticating with session keys,
6+
* where the actual signing is done by the session key wallet.
7+
*/
8+
9+
import { AbstractSigner, type Provider, type TransactionRequest } from 'ethers'
10+
11+
const cannotSign = (thing: string) =>
12+
`Cannot sign ${thing} - this is an address-only signer for session key authentication. Signing operations should be performed by the session key.`
13+
14+
export class AddressOnlySigner extends AbstractSigner {
15+
readonly address: string
16+
17+
constructor(address: string, provider?: Provider) {
18+
super(provider)
19+
this.address = address
20+
}
21+
22+
async getAddress(): Promise<string> {
23+
return this.address
24+
}
25+
26+
connect(provider: Provider): AddressOnlySigner {
27+
return new AddressOnlySigner(this.address, provider)
28+
}
29+
30+
async signTransaction(_tx: TransactionRequest): Promise<string> {
31+
throw new Error(cannotSign('transaction'))
32+
}
33+
34+
async signMessage(_message: string | Uint8Array): Promise<string> {
35+
throw new Error(cannotSign('message'))
36+
}
37+
38+
async signTypedData(_domain: any, _types: Record<string, any[]>, _value: Record<string, any>): Promise<string> {
39+
throw new Error(cannotSign('typed data'))
40+
}
41+
}

src/core/synapse/index.ts

Lines changed: 110 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
ADD_PIECES_TYPEHASH,
3+
CREATE_DATA_SET_TYPEHASH,
24
METADATA_KEYS,
35
type ProviderInfo,
46
RPC_URLS,
@@ -8,7 +10,9 @@ import {
810
Synapse,
911
type SynapseOptions,
1012
} from '@filoz/synapse-sdk'
13+
import { type Provider as EthersProvider, JsonRpcProvider, Wallet, WebSocketProvider } from 'ethers'
1114
import type { Logger } from 'pino'
15+
import { AddressOnlySigner } from './address-only-signer.js'
1216

1317
const WEBSOCKET_REGEX = /^ws(s)?:\/\//i
1418

@@ -51,13 +55,22 @@ export interface Config {
5155

5256
/**
5357
* Configuration for Synapse initialization
54-
* Extends the main Config but makes privateKey required and rpcUrl optional
58+
*
59+
* Supports two authentication modes:
60+
* 1. Standard: privateKey only
61+
* 2. Session Key: walletAddress + sessionKey
5562
*/
56-
export interface SynapseSetupConfig extends Partial<Omit<Config, 'privateKey' | 'rpcUrl'>> {
57-
/** Private key used for signing transactions. */
58-
privateKey: string
63+
export interface SynapseSetupConfig {
64+
/** Private key for standard authentication (mutually exclusive with session key mode) */
65+
privateKey?: string | undefined
66+
/** Wallet address for session key mode (requires sessionKey) */
67+
walletAddress?: string | undefined
68+
/** Session key private key (requires walletAddress) */
69+
sessionKey?: string | undefined
5970
/** RPC endpoint for the target Filecoin network. Defaults to calibration. */
6071
rpcUrl?: string | undefined
72+
/** Optional override for WarmStorage contract address */
73+
warmStorageAddress?: string | undefined
6174
}
6275

6376
/**
@@ -136,89 +149,122 @@ export function resetSynapseService(): void {
136149
activeProvider = null
137150
}
138151

152+
/**
153+
* Validate authentication configuration
154+
*/
155+
function validateAuthConfig(config: SynapseSetupConfig): 'standard' | 'session-key' {
156+
const hasStandardAuth = config.privateKey != null
157+
const hasSessionKeyAuth = config.walletAddress != null && config.sessionKey != null
158+
159+
if (!hasStandardAuth && !hasSessionKeyAuth) {
160+
throw new Error('Authentication required: provide either a privateKey or walletAddress + sessionKey')
161+
}
162+
163+
if (hasStandardAuth && hasSessionKeyAuth) {
164+
throw new Error('Conflicting authentication: provide either a privateKey or walletAddress + sessionKey, not both')
165+
}
166+
167+
return hasStandardAuth ? 'standard' : 'session-key'
168+
}
169+
170+
/**
171+
* Create ethers provider for the given RPC URL
172+
*/
173+
function createProvider(rpcURL: string): EthersProvider {
174+
if (WEBSOCKET_REGEX.test(rpcURL)) {
175+
return new WebSocketProvider(rpcURL)
176+
}
177+
return new JsonRpcProvider(rpcURL)
178+
}
179+
180+
/**
181+
* Setup and verify session key, throws if expired
182+
*/
183+
async function setupSessionKey(synapse: Synapse, sessionWallet: Wallet, logger: Logger): Promise<void> {
184+
const sessionKey = synapse.createSessionKey(sessionWallet)
185+
186+
// Verify permissions - fail fast if expired or expiring soon
187+
const expiries = await sessionKey.fetchExpiries([CREATE_DATA_SET_TYPEHASH, ADD_PIECES_TYPEHASH])
188+
const now = Math.floor(Date.now() / 1000)
189+
const bufferTime = 30 * 60 // 30 minutes in seconds
190+
const minValidTime = now + bufferTime
191+
const createExpiry = Number(expiries[CREATE_DATA_SET_TYPEHASH])
192+
const addExpiry = Number(expiries[ADD_PIECES_TYPEHASH])
193+
194+
if (createExpiry <= minValidTime || addExpiry <= minValidTime) {
195+
throw new Error(
196+
`Session key expired or expiring soon (requires 30+ minutes validity). CreateDataSet: ${new Date(createExpiry * 1000).toISOString()}, AddPieces: ${new Date(addExpiry * 1000).toISOString()}`
197+
)
198+
}
199+
200+
logger.info({ event: 'synapse.session_key.verified', createExpiry, addExpiry }, 'Session key verified')
201+
202+
synapse.setSession(sessionKey)
203+
logger.info({ event: 'synapse.session_key.activated' }, 'Session key activated')
204+
}
205+
139206
/**
140207
* Initialize the Synapse SDK without creating storage context
141208
*
142-
* This function initializes the Synapse SDK connection without creating
143-
* a storage context. This method is primarily a wrapper for handling our
144-
* custom configuration needs and adding detailed logging.
209+
* Supports two authentication modes:
210+
* - Standard: privateKey only
211+
* - Session Key: walletAddress + sessionKey
145212
*
146-
* @param config - Application configuration with privateKey and RPC URL
213+
* @param config - Application configuration with authentication credentials
147214
* @param logger - Logger instance for detailed operation tracking
148215
* @returns Initialized Synapse instance
149216
*/
150217
export async function initializeSynapse(config: SynapseSetupConfig, logger: Logger): Promise<Synapse> {
151218
try {
152-
// Log the configuration status
153-
logger.info(
154-
{
155-
hasPrivateKey: config.privateKey != null,
156-
rpcUrl: config.rpcUrl,
157-
},
158-
'Initializing Synapse'
159-
)
219+
const authMode = validateAuthConfig(config)
220+
const rpcURL = config.rpcUrl ?? RPC_URLS.calibration.websocket
160221

161-
// IMPORTANT: Private key is required for transaction signing
162-
// In production, this should come from secure environment variables, or a wallet integration
163-
const privateKey = config.privateKey
164-
if (privateKey == null) {
165-
const error = new Error('PRIVATE_KEY environment variable is required for Synapse integration')
166-
logger.error(
167-
{
168-
event: 'synapse.init.failed',
169-
error: error.message,
170-
},
171-
'Synapse initialization failed: missing PRIVATE_KEY'
172-
)
173-
throw error
174-
}
175-
176-
logger.info({ event: 'synapse.init' }, 'Initializing Synapse SDK')
177-
178-
// Configure Synapse with network settings
179-
// Network options: 314 (mainnet) or 314159 (calibration testnet)
180-
const synapseOptions: SynapseOptions = {
181-
privateKey,
182-
rpcURL: config.rpcUrl ?? RPC_URLS.calibration.websocket, // Default to calibration testnet
183-
}
222+
logger.info({ event: 'synapse.init', authMode, rpcUrl: rpcURL }, 'Initializing Synapse SDK')
184223

185-
// Optional: Override the default Warm Storage contract address
186-
// Useful for testing with custom deployments
187-
if (config.warmStorageAddress != null) {
224+
const synapseOptions: SynapseOptions = { rpcURL }
225+
if (config.warmStorageAddress) {
188226
synapseOptions.warmStorageAddress = config.warmStorageAddress
189227
}
190228

191-
const synapse = await Synapse.create(synapseOptions)
192-
193-
// Store reference to the provider for cleanup if it's a WebSocket provider
194-
if (synapseOptions.rpcURL && WEBSOCKET_REGEX.test(synapseOptions.rpcURL)) {
229+
let synapse: Synapse
230+
231+
if (authMode === 'session-key') {
232+
// Session key mode - validation guarantees these are defined
233+
const walletAddress = config.walletAddress
234+
const sessionKey = config.sessionKey
235+
if (!walletAddress || !sessionKey) {
236+
throw new Error('Internal error: session key config validated but values missing')
237+
}
238+
239+
// Create provider and signers for session key mode
240+
const provider = createProvider(rpcURL)
241+
activeProvider = provider
242+
243+
const ownerSigner = new AddressOnlySigner(walletAddress, provider)
244+
const sessionWallet = new Wallet(sessionKey, provider)
245+
246+
// Initialize with owner signer, then activate session key
247+
synapse = await Synapse.create({ ...synapseOptions, signer: ownerSigner })
248+
await setupSessionKey(synapse, sessionWallet, logger)
249+
} else {
250+
// Standard mode - validation guarantees privateKey is defined
251+
const privateKey = config.privateKey
252+
if (!privateKey) {
253+
throw new Error('Internal error: standard auth validated but privateKey missing')
254+
}
255+
256+
synapse = await Synapse.create({ ...synapseOptions, privateKey })
195257
activeProvider = synapse.getProvider()
196258
}
197259

198-
// Get network info for logging
199260
const network = synapse.getNetwork()
200-
logger.info(
201-
{
202-
event: 'synapse.init',
203-
network,
204-
rpcUrl: synapseOptions.rpcURL,
205-
},
206-
'Synapse SDK initialized'
207-
)
261+
logger.info({ event: 'synapse.init.success', network }, 'Synapse SDK initialized')
208262

209-
// Store instance for cleanup
210263
synapseInstance = synapse
211-
212264
return synapse
213265
} catch (error) {
214266
const errorMessage = error instanceof Error ? error.message : String(error)
215-
logger.error(
216-
{
217-
event: 'synapse.init.failed',
218-
error: errorMessage,
219-
},
220-
`Failed to initialize Synapse SDK: ${errorMessage}`
221-
)
267+
logger.error({ event: 'synapse.init.failed', error: errorMessage }, 'Failed to initialize Synapse SDK')
222268
throw error
223269
}
224270
}

src/core/unixfs/browser-car-builder.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { unixfs } from '@helia/unixfs'
9-
import { CarWriter } from '@ipld/car'
9+
import { CarReader, CarWriter } from '@ipld/car'
1010
import { CID } from 'multiformats/cid'
1111
import { CARWritingBlockstore } from '../car/browser-car-blockstore.js'
1212

@@ -251,9 +251,6 @@ async function updateRootCidInCar(carBytes: Uint8Array, rootCid: CID): Promise<U
251251
// We need to replace the placeholder CID with the actual root CID
252252
// The easiest way is to re-read the CAR and write a new one with the correct root
253253

254-
// Import CarReader to read the existing CAR
255-
const { CarReader } = await import('@ipld/car')
256-
257254
const reader = await CarReader.fromBytes(carBytes)
258255

259256
// Create new CAR writer with correct root

src/import/import.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,24 @@ export async function runCarImport(options: ImportOptions): Promise<ImportResult
165165
// Step 4: Initialize Synapse SDK (without storage context)
166166
spinner.start('Initializing Synapse SDK...')
167167

168-
if (!options.privateKey) {
169-
spinner.stop(`${pc.red('✗')} Private key required via --private-key or PRIVATE_KEY env`)
170-
cancel('Import cancelled')
168+
// Check for session key auth (env vars only for now)
169+
const walletAddress = process.env.WALLET_ADDRESS
170+
const sessionKey = process.env.SESSION_KEY
171+
172+
// Validate authentication (either standard or session key mode)
173+
const hasStandardAuth = options.privateKey != null
174+
const hasSessionKeyAuth = walletAddress != null && sessionKey != null
175+
176+
if (!hasStandardAuth && !hasSessionKeyAuth) {
177+
spinner.stop(`${pc.red('✗')} Authentication required`)
178+
cancel('Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars')
171179
process.exit(1)
172180
}
173181

174182
const config = {
175183
privateKey: options.privateKey,
184+
walletAddress,
185+
sessionKey,
176186
rpcUrl: options.rpcUrl || RPC_URLS.calibration.websocket,
177187
// Other config fields not needed for import
178188
port: 0,

src/logger.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { type Logger, pino } from 'pino'
2-
import type { SynapseSetupConfig } from './core/synapse/index.js'
32

4-
export function createLogger(config: Pick<SynapseSetupConfig, 'logLevel'>): Logger {
3+
export function createLogger(config: { logLevel?: string }): Logger {
54
return pino({
65
level: config.logLevel ?? 'info',
76
})

src/test/unit/dataset-management.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('Dataset Management', () => {
2525
privateKey: '0x0000000000000000000000000000000000000000000000000000000000000001',
2626
rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1',
2727
}
28-
logger = createLogger(config)
28+
logger = createLogger({ logLevel: 'info' })
2929
resetSynapseService()
3030
vi.clearAllMocks()
3131
})

src/test/unit/import.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,9 @@ describe('CAR Import', () => {
364364
}
365365

366366
await expect(runCarImport(options)).rejects.toThrow('process.exit called')
367-
expect(consoleMocks.error).toHaveBeenCalledWith('Import cancelled')
367+
expect(consoleMocks.error).toHaveBeenCalledWith(
368+
'Provide either PRIVATE_KEY or both WALLET_ADDRESS + SESSION_KEY env vars'
369+
)
368370
})
369371

370372
it('should use custom RPC URL if provided', async () => {

src/test/unit/payments-setup.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ describe('Payment Setup Tests', () => {
7474
mockSynapse = {
7575
getProvider: vi.fn().mockReturnValue(mockProvider),
7676
getSigner: vi.fn().mockReturnValue(mockSigner),
77+
getClient: vi.fn().mockReturnValue(mockSigner),
7778
getNetwork: vi.fn().mockReturnValue('calibration'),
7879
getPaymentsAddress: vi.fn().mockReturnValue('0xpayments'),
7980
getWarmStorageAddress: vi.fn().mockReturnValue('0xwarmstorage'),

0 commit comments

Comments
 (0)