From 71b7ffe6b766c30983084b800e1656c56e9cb64a Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 15:57:03 +0100 Subject: [PATCH] WIP authorizer allowlist implementation --- cmd/curio/main.go | 1 + cuhttp/server.go | 13 ++++--- deps/config/doc_gen.go | 82 +++++++++++++++++++++++++++++++++++++++ deps/config/types.go | 71 +++++++++++++++++++++++++++++++++ pdp/handlers.go | 20 +++++++++- pdp/handlers_add.go | 9 +++++ pdp/handlers_pull.go | 39 ++++++++++++------- pdp/handlers_terminate.go | 24 ++++++++++++ pdp/mount.go | 12 +++++- pdpnode/routes.go | 13 ++++--- 10 files changed, 255 insertions(+), 29 deletions(-) diff --git a/cmd/curio/main.go b/cmd/curio/main.go index 1c65c5e0f..26f4a1f5a 100644 --- a/cmd/curio/main.go +++ b/cmd/curio/main.go @@ -78,6 +78,7 @@ func main() { calcCmd, toolboxCmd, batchCmd, + pdpCmd, } for _, cmd := range local { diff --git a/cuhttp/server.go b/cuhttp/server.go index 1a7bcb197..5b6816ae0 100644 --- a/cuhttp/server.go +++ b/cuhttp/server.go @@ -97,12 +97,13 @@ func attachRouters(ctx context.Context, r *chi.Mux, d *deps.Deps, sd *ServiceDep if sd.EthSender != nil { if err := pdp.MountRoutes(ctx, r, pdp.MountDeps{ - DB: d.DB, - LocalStore: d.LocalStore, - EthClient: must.One(d.EthClient.Get()), - Chain: d.Chain, - EthSender: sd.EthSender, - AlertTask: sd.AlertTask, + DB: d.DB, + LocalStore: d.LocalStore, + EthClient: must.One(d.EthClient.Get()), + Chain: d.Chain, + EthSender: sd.EthSender, + AlertTask: sd.AlertTask, + AuthorizerConfig: d.Cfg.Subsystems.PDPAuthorizers, }, ipp); err != nil { return nil, err } diff --git a/deps/config/doc_gen.go b/deps/config/doc_gen.go index dab0967b9..39ff0d05a 100644 --- a/deps/config/doc_gen.go +++ b/deps/config/doc_gen.go @@ -47,6 +47,47 @@ Required when URL is a stateless /notify endpoint; leave empty for a stateful /n Comment: `Tag restricts delivery to Apprise URLs carrying this tag. Only applies to stateful configs. OPTIONAL.`, }, }, + "ApprovedAuthorizer": { + { + Name: "Label", + Type: "string", + + Comment: `Label is a human-readable name shown in logs and metrics.`, + }, + { + Name: "Kind", + Type: "string", + + Comment: `Kind selects how the on-chain authorizer is matched: +"implementation" - accept any EIP-1167 minimal-proxy clone whose hardcoded implementation +equals Address. This is the preferred deployment shape: audited logic is +deployed once, and each client's clone has its own storage and cannot be +upgraded (the implementation is baked into the 45-byte clone bytecode). +"codehash" - accept a fixed-logic (non-proxy) deploy whose normalized runtime-code hash +equals CodeHash. Upgradeable proxies are always refused regardless of Kind, +because their logic can change after approval.`, + }, + { + Name: "Address", + Type: "string", + + Comment: `Address is the audited implementation address, 0x-prefixed. Used when Kind = "implementation".`, + }, + { + Name: "CodeHash", + Type: "string", + + Comment: `CodeHash is the normalized runtime-code hash, a 0x-prefixed keccak256. Used when Kind = "codehash". +The hash is taken over the runtime bytecode after stripping the trailing Solidity CBOR metadata +blob. Compute it with "curio pdp authorizer-id".`, + }, + { + Name: "Notes", + Type: "string", + + Comment: `Notes is free-form: audit reference, gas profile, etc.`, + }, + }, "BalanceManagerConfig": { { Name: "MK12Collateral", @@ -999,6 +1040,14 @@ It periodically runs ANALYZE on tables whose write churn (pg_stat_user_tables) h by 10% since the last analyze. Disable this if you manage table statistics outside Curio. (Default: true)`, }, + { + Name: "PDPAuthorizers", + Type: "PDPAuthorizerConfig", + + Comment: `PDPAuthorizers governs which IDataSetAuthorizer contracts this SP is willing to spend gas on when +relaying authorizer-gated operations (FWSS PR #536). It applies to the shared PDP service and is +therefore version-agnostic (PDPv0 and PDPv1/MK20 alike). See PDPAuthorizerConfig.`, + }, }, "CuzkConfig": { { @@ -1354,6 +1403,39 @@ If True then all deals coming from unknown clients will be rejected. (Default: f Comment: `StorageMarketConfig houses all the deal related market configuration`, }, }, + "PDPAuthorizerConfig": { + { + Name: "RequireAllowlistedAuthorizer", + Type: "bool", + + Comment: `RequireAllowlistedAuthorizer means an authorizer-gated operation is relayed only when the data +set's authorizer CODE matches an ApprovedAuthorizers entry. Matching is by code identity, not +address, so each client can run their own isolated instance of approved logic. + +The default is true and is deliberately fail-closed on the gas-spending path: a fresh SP ships +with an EMPTY ApprovedAuthorizers list, so it relays only the un-authorized default payer/session +path (where it fronts no isAuthorized gas) until the operator explicitly opts specific code in. +Setting this to false relays for ANY authorizer and is NOT recommended, because the SP then fronts +up to the 150M-gas isAuthorized call for authorizer code it has never reviewed. (Default: true)`, + }, + { + Name: "ApprovedAuthorizers", + Type: "[]ApprovedAuthorizer", + + Comment: `ApprovedAuthorizers lists the authorizer code identities this SP will spend gas on. Each entry is +matched by CODE (see ApprovedAuthorizer.Kind), never by the authorizer's own address, so a client +can deploy their own isolated instance (own owner, own registry) of approved logic. Empty by +default. Only consulted when RequireAllowlistedAuthorizer is true.`, + }, + { + Name: "MaxAuthorizerGas", + Type: "uint64", + + Comment: `MaxAuthorizerGas is the eth_call gas limit applied to authorizer-gated relay preflights +(addPieces / schedulePieceDeletions / terminateService). 0 means the 150M on-chain +AUTHORIZER_GAS_LIMIT; values above 150M are clamped to it. (Default: 150000000)`, + }, + }, "PagerDutyConfig": { { Name: "Enable", diff --git a/deps/config/types.go b/deps/config/types.go index e7ff2b5a2..31e0eba00 100644 --- a/deps/config/types.go +++ b/deps/config/types.go @@ -22,6 +22,13 @@ func DefaultCurioConfig() *CurioConfig { RemoteProofMaxUploads: 15, ParkPieceMinFreeStoragePercent: 5, EnableDBAnalyze: true, + // Fail closed on the gas-spending path: require an allowlisted authorizer by default, + // but ship with an empty list so a fresh SP only relays the un-authorized default path + // until the operator opts specific authorizer code in. + PDPAuthorizers: PDPAuthorizerConfig{ + RequireAllowlistedAuthorizer: true, + MaxAuthorizerGas: 150_000_000, + }, }, Fees: CurioFees{ MaxPreCommitBatchGasFee: BatchFeeConfig{ @@ -478,6 +485,70 @@ type CurioSubsystemsConfig struct { // by 10% since the last analyze. // Disable this if you manage table statistics outside Curio. (Default: true) EnableDBAnalyze bool + + // PDPAuthorizers governs which IDataSetAuthorizer contracts this SP is willing to spend gas on when + // relaying authorizer-gated operations (FWSS PR #536). It applies to the shared PDP service and is + // therefore version-agnostic (PDPv0 and PDPv1/MK20 alike). See PDPAuthorizerConfig. + PDPAuthorizers PDPAuthorizerConfig +} + +// PDPAuthorizerConfig is the SP-side policy for relaying authorizer-gated PDP operations. +// +// filecoin-services PR #536 lets a data set's payer attach an IDataSetAuthorizer contract that gates +// add-pieces / schedule-removals / immediate-terminate. When the SP relays one of those operations, the +// SP's transaction pays for the authorizer's isAuthorized call - up to the 150M-gas AUTHORIZER_GAS_LIMIT +// (a full P256/passkey verification is ~110-123M). A hostile or buggy authorizer can burn that gas on +// every relay, so the SP keeps an off-chain policy of which authorizer CODE it trusts. +type PDPAuthorizerConfig struct { + // RequireAllowlistedAuthorizer means an authorizer-gated operation is relayed only when the data + // set's authorizer CODE matches an ApprovedAuthorizers entry. Matching is by code identity, not + // address, so each client can run their own isolated instance of approved logic. + // + // The default is true and is deliberately fail-closed on the gas-spending path: a fresh SP ships + // with an EMPTY ApprovedAuthorizers list, so it relays only the un-authorized default payer/session + // path (where it fronts no isAuthorized gas) until the operator explicitly opts specific code in. + // Setting this to false relays for ANY authorizer and is NOT recommended, because the SP then fronts + // up to the 150M-gas isAuthorized call for authorizer code it has never reviewed. (Default: true) + RequireAllowlistedAuthorizer bool + + // ApprovedAuthorizers lists the authorizer code identities this SP will spend gas on. Each entry is + // matched by CODE (see ApprovedAuthorizer.Kind), never by the authorizer's own address, so a client + // can deploy their own isolated instance (own owner, own registry) of approved logic. Empty by + // default. Only consulted when RequireAllowlistedAuthorizer is true. + ApprovedAuthorizers []ApprovedAuthorizer + + // MaxAuthorizerGas is the eth_call gas limit applied to authorizer-gated relay preflights + // (addPieces / schedulePieceDeletions / terminateService). 0 means the 150M on-chain + // AUTHORIZER_GAS_LIMIT; values above 150M are clamped to it. (Default: 150000000) + MaxAuthorizerGas uint64 +} + +// ApprovedAuthorizer is one entry in the SP's authorizer allowlist. It identifies trusted authorizer +// logic by CODE so a client can run their own instance of it. +type ApprovedAuthorizer struct { + // Label is a human-readable name shown in logs and metrics. + Label string + + // Kind selects how the on-chain authorizer is matched: + // "implementation" - accept any EIP-1167 minimal-proxy clone whose hardcoded implementation + // equals Address. This is the preferred deployment shape: audited logic is + // deployed once, and each client's clone has its own storage and cannot be + // upgraded (the implementation is baked into the 45-byte clone bytecode). + // "codehash" - accept a fixed-logic (non-proxy) deploy whose normalized runtime-code hash + // equals CodeHash. Upgradeable proxies are always refused regardless of Kind, + // because their logic can change after approval. + Kind string + + // Address is the audited implementation address, 0x-prefixed. Used when Kind = "implementation". + Address string + + // CodeHash is the normalized runtime-code hash, a 0x-prefixed keccak256. Used when Kind = "codehash". + // The hash is taken over the runtime bytecode after stripping the trailing Solidity CBOR metadata + // blob. Compute it with "curio pdp authorizer-id". + CodeHash string + + // Notes is free-form: audit reference, gas profile, etc. + Notes string } type CurioFees struct { // maxBatchFee = maxBase + maxPerSector * nSectors diff --git a/pdp/handlers.go b/pdp/handlers.go index 7f5767501..6537329cf 100644 --- a/pdp/handlers.go +++ b/pdp/handlers.go @@ -87,6 +87,9 @@ type PDPService struct { ipp *ipni_provider.Provider ipOffenseThrottle *IPOffenseThrottle + + // ethValidator is the shared eth_call helper, including the authorizer allowlist check. + ethValidator *EthCallValidator } type PDPServiceNodeApi interface { @@ -102,10 +105,11 @@ func NewPDPService( fc PDPServiceNodeApi, sn ETHTxSender, alertTask *alertmanager.AlertTask, - ipp *ipni_provider.Provider) *PDPService { + ipp *ipni_provider.Provider, + authPolicy *AuthorizerAllowlist) *PDPService { auth := &NullAuth{} pullStore := NewDBPullStore(db) - pullValidator := NewEthCallValidator(ec, db) + pullValidator := NewEthCallValidator(ec, db, authPolicy) p := &PDPService{ Auth: auth, @@ -123,6 +127,8 @@ func NewPDPService( ipp: ipp, ipOffenseThrottle: NewIPOffenseThrottle(defaultIPOffensePolicies()), + + ethValidator: pullValidator, } go p.ipOffenseThrottle.RunCleanup(ctx) @@ -1041,6 +1047,11 @@ func (p *PDPService) handleDeleteDataSetPiece(w http.ResponseWriter, r *http.Req http.Error(w, "Data set not found", http.StatusNotFound) return } + + if p.refuseUnallowlistedAuthorizer(w, ctx, dataSetId) { + return + } + type DeletePiecePayload struct { ExtraData *string `json:"extraData"` PieceIDs []uint64 `json:"pieceIds"` @@ -1143,6 +1154,11 @@ func (p *PDPService) handleDeleteDataSetPiece(w http.ResponseWriter, r *http.Req return } + if err := p.preflightAuthorizerCall(ctx, fromAddress, contract.ContractAddresses().PDPVerifier, data, nil); err != nil { + httpServerError(w, http.StatusBadRequest, "schedulePieceDeletions validation failed: "+err.Error(), err) + return + } + // Prepare the transaction ethTx := types.NewTransaction( 0, // nonce will be set by SenderETH diff --git a/pdp/handlers_add.go b/pdp/handlers_add.go index 09284ed31..52716f2dc 100644 --- a/pdp/handlers_add.go +++ b/pdp/handlers_add.go @@ -354,6 +354,10 @@ func (p *PDPService) handleAddPieceToDataSet(w http.ResponseWriter, r *http.Requ return } + if p.refuseUnallowlistedAuthorizer(w, ctx, dataSetIdUint64) { + return + } + // Convert dataSetId to *big.Int dataSetId := new(big.Int).SetUint64(dataSetIdUint64) @@ -395,6 +399,11 @@ func (p *PDPService) handleAddPieceToDataSet(w http.ResponseWriter, r *http.Requ return } + if err := p.preflightAuthorizerCall(ctx, fromAddress, contract.ContractAddresses().PDPVerifier, data, nil); err != nil { + httpServerError(w, http.StatusBadRequest, "addPieces validation failed: "+err.Error(), err) + return + } + // Prepare the transaction (nonce will be set to 0, SenderETH will assign it) txEth := types.NewTransaction( 0, diff --git a/pdp/handlers_pull.go b/pdp/handlers_pull.go index 052b3074a..d1be21e42 100644 --- a/pdp/handlers_pull.go +++ b/pdp/handlers_pull.go @@ -9,7 +9,6 @@ import ( "math/big" "net/http" - "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -59,14 +58,29 @@ type EthCallValidator struct { ethClient ethchain.EthClient db *harmonydb.DB senderAddr common.Address // cached, lazily loaded + + // authorizerPolicy gates which IDataSetAuthorizer contracts the SP will spend gas relaying for. + // May be nil (treated as opt-out). + authorizerPolicy *AuthorizerAllowlist } -// NewEthCallValidator creates a validator that uses eth_call -func NewEthCallValidator(ethClient ethchain.EthClient, db *harmonydb.DB) *EthCallValidator { - return &EthCallValidator{ethClient: ethClient, db: db} +// NewEthCallValidator creates a validator that uses eth_call. authorizerPolicy may be nil, which +// disables the authorizer allowlist check (opt-out). +func NewEthCallValidator(ethClient ethchain.EthClient, db *harmonydb.DB, authorizerPolicy *AuthorizerAllowlist) *EthCallValidator { + return &EthCallValidator{ethClient: ethClient, db: db, authorizerPolicy: authorizerPolicy} } func (v *EthCallValidator) ValidateAddPieces(ctx context.Context, params *AddPiecesValidatorParams) error { + if params.DataSetId == nil { + return fmt.Errorf("dataSetId is required") + } + + // Allowlist before the addPieces eth_call: that simulation executes isAuthorized once #536 is + // live, so an unapproved authorizer must not reach it. + if err := v.checkAuthorizerAllowed(ctx, params.DataSetId.Uint64(), params.RecordKeeper); err != nil { + return err + } + // Lazily load sender address if not cached if v.senderAddr == (common.Address{}) && v.db != nil { addr, err := getPDPSenderAddress(ctx, v.db) @@ -95,7 +109,8 @@ func (v *EthCallValidator) ValidateAddPieces(ctx context.Context, params *AddPie return fmt.Errorf("failed to pack addPieces call: %w", err) } - // eth_call to validate — match tx value used for dataset creation + // eth_call to validate — match tx value used for dataset creation. Gas-capped so isAuthorized + // cannot spin past MaxAuthorizerGas (default 150M). value := big.NewInt(0) if isCreateNew { value, err = contract.FilCleanupDeposit(ctx, v.ethClient) @@ -103,15 +118,7 @@ func (v *EthCallValidator) ValidateAddPieces(ctx context.Context, params *AddPie return fmt.Errorf("reading FIL cleanup deposit: %w", err) } } - msg := ethereum.CallMsg{ - From: v.senderAddr, - To: new(contract.ContractAddresses().PDPVerifier), - Data: data, - Value: value, - } - - _, err = v.ethClient.CallContract(ctx, msg, nil) - if err != nil { + if err := v.callWithAuthorizerGas(ctx, v.senderAddr, contract.ContractAddresses().PDPVerifier, data, value); err != nil { return fmt.Errorf("addPieces validation failed: %w", err) } @@ -404,6 +411,10 @@ func (h *PullHandler) HandlePull(w http.ResponseWriter, r *http.Request) { ExtraData: extraDataBytes, } if err := h.validator.ValidateAddPieces(ctx, validatorParams); err != nil { + if errors.Is(err, ErrAuthorizerNotAllowlisted) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } httpServerError(w, http.StatusBadRequest, "extraData validation failed: "+err.Error(), err) return } diff --git a/pdp/handlers_terminate.go b/pdp/handlers_terminate.go index 8e526ce7a..aa8b965c6 100644 --- a/pdp/handlers_terminate.go +++ b/pdp/handlers_terminate.go @@ -112,6 +112,10 @@ func (p *PDPService) handleTerminateDataSet(w http.ResponseWriter, r *http.Reque return } + if p.refuseUnallowlistedAuthorizer(w, ctx, dataSetID) { + return + } + terminationEpoch, terminated, err := p.getFWSSServiceTerminationEpoch(ctx, dataSetID) if err != nil { httpServerError(w, http.StatusInternalServerError, "Failed to check FWSS termination state", err) @@ -155,6 +159,26 @@ func (p *PDPService) handleTerminateDataSet(w http.ResponseWriter, r *http.Reque return } + fwssABI, err := FWSS.FilecoinWarmStorageServiceMetaData.GetAbi() + if err != nil { + httpServerError(w, http.StatusInternalServerError, "Failed to get FWSS ABI", err) + return + } + calldata, err := fwssABI.Pack("terminateService0", new(big.Int).SetUint64(dataSetID), extraDataBytes) + if err != nil { + httpServerError(w, http.StatusInternalServerError, "Failed to pack terminateService", err) + return + } + fromAddress, err := p.getSenderAddress(ctx) + if err != nil { + httpServerError(w, http.StatusInternalServerError, "Failed to get sender address", err) + return + } + if err := p.preflightAuthorizerCall(ctx, fromAddress, fwssAddr, calldata, nil); err != nil { + httpServerError(w, http.StatusBadRequest, "termination extraData validation failed: "+err.Error(), err) + return + } + n, err := p.db.Exec(ctx, ` INSERT INTO pdp_delete_data_set ( id, diff --git a/pdp/mount.go b/pdp/mount.go index 902500ce2..58ca10246 100644 --- a/pdp/mount.go +++ b/pdp/mount.go @@ -8,6 +8,7 @@ import ( "github.com/filecoin-project/curio/alertmanager" "github.com/filecoin-project/curio/api" + "github.com/filecoin-project/curio/deps/config" "github.com/filecoin-project/curio/harmony/harmonydb" "github.com/filecoin-project/curio/lib/ethchain" "github.com/filecoin-project/curio/lib/paths" @@ -22,6 +23,10 @@ type MountDeps struct { Chain api.Chain EthSender ETHTxSender AlertTask *alertmanager.AlertTask + + // AuthorizerConfig is the SP-side authorizer allowlist policy (Subsystems.PDPAuthorizers). The + // zero value opts out (relays for any authorizer); real callers pass Cfg.Subsystems.PDPAuthorizers. + AuthorizerConfig config.PDPAuthorizerConfig } // MountRoutes registers PDP HTTP routes on an existing router. @@ -30,7 +35,12 @@ func MountRoutes(ctx context.Context, r chi.Router, d MountDeps, ipp *ipni_provi return xerrors.Errorf("eth sender required for PDP routes") } - pdsvc := NewPDPService(ctx, d.DB, d.LocalStore, d.EthClient, d.Chain, d.EthSender, d.AlertTask, ipp) + authPolicy, err := NewAuthorizerAllowlist(d.AuthorizerConfig) + if err != nil { + return xerrors.Errorf("building PDP authorizer allowlist: %w", err) + } + + pdsvc := NewPDPService(ctx, d.DB, d.LocalStore, d.EthClient, d.Chain, d.EthSender, d.AlertTask, ipp, authPolicy) Routes(r, pdsvc) return nil } diff --git a/pdpnode/routes.go b/pdpnode/routes.go index 4f37bf21c..1e3a6ca99 100644 --- a/pdpnode/routes.go +++ b/pdpnode/routes.go @@ -16,12 +16,13 @@ import ( // MountPDPRoutes attaches PDP HTTP routes using an existing IPNI provider. func MountPDPRoutes(ctx context.Context, r chi.Router, d *Deps, sd *servicedeps.Deps, ipp *ipni_provider.Provider) error { return pdp.MountRoutes(ctx, r, pdp.MountDeps{ - DB: d.DB, - LocalStore: d.LocalStore, - EthClient: must.One(d.EthClient.Val()), - Chain: d.Chain, - EthSender: sd.EthSender, - AlertTask: sd.AlertTask, + DB: d.DB, + LocalStore: d.LocalStore, + EthClient: must.One(d.EthClient.Val()), + Chain: d.Chain, + EthSender: sd.EthSender, + AlertTask: sd.AlertTask, + AuthorizerConfig: d.Cfg.Subsystems.PDPAuthorizers, }, ipp) }