-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add session key authentication support #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can rename this to user or client |
||
| const address = await signer.getAddress() | ||
|
|
||
| // Get native token balance | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. surprising that ethers doesn't already have something like this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hugo promises me that Viem solves this for us so we can separate read and write wallets so I'm hopeful that this file is temporary until we get those changes into Synapse. |
||
|
|
||
| constructor(address: string, provider?: Provider) { | ||
| super(provider) | ||
| this.address = address | ||
| } | ||
|
|
||
| async getAddress(): Promise<string> { | ||
| return this.address | ||
| } | ||
|
|
||
| connect(provider: Provider): AddressOnlySigner { | ||
| return new AddressOnlySigner(this.address, provider) | ||
| } | ||
|
|
||
| async signTransaction(_tx: TransactionRequest): Promise<string> { | ||
| throw new Error(cannotSign('transaction')) | ||
| } | ||
|
|
||
| async signMessage(_message: string | Uint8Array): Promise<string> { | ||
| throw new Error(cannotSign('message')) | ||
| } | ||
|
|
||
| async signTypedData(_domain: any, _types: Record<string, any[]>, _value: Record<string, any>): Promise<string> { | ||
| throw new Error(cannotSign('typed data')) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Omit<Config, 'privateKey' | 'rpcUrl'>> { | ||
| /** 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' | ||
| } | ||
|
Comment on lines
+155
to
+168
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should use a typeguard and two strict types (descriminating union). maybe its mostly personal preference, but resolving optional types everywhere has always been a pain for me, but it should also prevent invalid permutations of a wide/loose interface. It also gives us better type validation at build time rather than runtime, makes refactoring/extending easier.. etc.. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, go for it, or just a boolean |
||
|
|
||
| /** | ||
| * 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<void> { | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 30min is probably good for now, but do we need 30 minutes validity for the future? most operations take < 10min There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I started this with 5 minutes, but then I thought that when we start dealing with larger files we're doing this check potentially very long before we even get to submit something to the chain, so let's leave a big buffer |
||
| 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<Synapse> { | ||
| 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 }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will fail if this app does any payments stuff. Does it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if you have a properly funded wallet then it should be fine, it's one of the reasons I didn't add any documentation or CLI args for this feature because I didn't want to have to deal with the complexity of that - it should only be used by wallets that have enough funds and are authorised at the right level @SgtPooki added some help in #110 to make that problem a little less annoying, but now I think we're at the problem of avoiding the checks and ending up with reverts if we really don't have a set up wallet. |
||
| 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') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is overall much cleaner than previous.. |
||
|
|
||
| // 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 | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<U | |
| // We need to replace the placeholder CID with the actual root CID | ||
| // The easiest way is to re-read the CAR and write a new one with the correct root | ||
|
|
||
| // Import CarReader to read the existing CAR | ||
| const { CarReader } = await import('@ipld/car') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oops |
||
|
|
||
| const reader = await CarReader.fromBytes(carBytes) | ||
|
|
||
| // Create new CAR writer with correct root | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -165,14 +165,24 @@ export async function runCarImport(options: ImportOptions): Promise<ImportResult | |
| // Step 4: 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('Import 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this also duplicates the validation logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same as above, I hate it, but not enough to wrestle with it |
||
| 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 import | ||
| port: 0, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| import { type Logger, pino } from 'pino' | ||
| import type { SynapseSetupConfig } from './core/synapse/index.js' | ||
|
|
||
| export function createLogger(config: Pick<SynapseSetupConfig, 'logLevel'>): Logger { | ||
| export function createLogger(config: { logLevel?: string }): Logger { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this should be |
||
| return pino({ | ||
| level: config.logLevel ?? 'info', | ||
| }) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is duplicated in validateAuthConfig
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, I know, it only exists to deal with TypeScript complaints, original version didn't have it, then linter made me make it jankier, there's probably a better way of dealing with this but this was my expedient version
@SgtPooki if you're going to be refactoring the typeguard for that function then maybe this can be solved at the same time?