From c8e674f369005b9bc0835f1550848c0af353f4ea Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 29 Apr 2026 00:09:24 +0200 Subject: [PATCH] byoc: support offchain mode without an Eth keystore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In -network offchain mode the broadcaster's Sign() returns empty bytes (core/broadcaster.go:14) and the orchestrator's VerifySig() short-circuits to true (core/orchestrator.go:66) — both already offchain-aware. BYOC should inherit that behaviour but didn't because: - byoc/job_gateway.go's getJobSender always prefixed the encoded signature with "0x", producing the literal string "0x" for empty sigs. - byoc/job_orchestrator.go's verifyTokenCreds only stripped the prefix when len > 130, so "0x" passed through to hex.DecodeString and failed with "invalid byte: U+0078 'x'", aborting before VerifySig's offchain bypass could run. Fix: - encodeJobSig() returns "" for empty sigs, "0x" + hex otherwise. - verifyTokenCreds() unconditionally trims a "0x" prefix; an empty sig now decodes to empty bytes and VerifySig short-circuits to true offchain. Also fixes a missing format verb in the existing "Unable to hex-decode signature" log line. Tests cover both helpers plus the verifyTokenCreds round-trip with empty and "0x"-prefixed sigs. After this change a BYOC stack can run with just -network offchain on both gateway and orchestrator — no -ethPassword, no auto-generated keystore, no -ethUrl. Co-Authored-By: Claude Opus 4.7 (1M context) --- byoc/job_gateway.go | 12 +++++++- byoc/job_gateway_test.go | 12 ++++++++ byoc/job_orchestrator.go | 10 +++---- byoc/job_orchestrator_test.go | 52 +++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/byoc/job_gateway.go b/byoc/job_gateway.go index f217442008..dd85ba5653 100644 --- a/byoc/job_gateway.go +++ b/byoc/job_gateway.go @@ -444,12 +444,22 @@ func getJobSender(ctx context.Context, node *core.LivepeerNode) (*JobSender, err addr := ethcommon.BytesToAddress(orchReq.Address) jobSender := &JobSender{ Addr: addr.Hex(), - Sig: "0x" + hex.EncodeToString(orchReq.Sig), + Sig: encodeJobSig(orchReq.Sig), } return jobSender, nil } +// encodeJobSig hex-encodes a signature with the "0x" prefix. Returns an empty +// string for empty signatures, which can occur in offchain mode when the +// broadcaster has no Eth keystore (see core.broadcaster.Sign). +func encodeJobSig(sig []byte) string { + if len(sig) == 0 { + return "" + } + return "0x" + hex.EncodeToString(sig) +} + func genOrchestratorReq(b common.Broadcaster) (*net.OrchestratorRequest, error) { sig, err := b.Sign([]byte(fmt.Sprintf("%v", b.Address().Hex()))) if err != nil { diff --git a/byoc/job_gateway_test.go b/byoc/job_gateway_test.go index dffa73a279..5f7c8b1aa7 100644 --- a/byoc/job_gateway_test.go +++ b/byoc/job_gateway_test.go @@ -426,3 +426,15 @@ func TestSetupGatewayJob(t *testing.T) { assert.Error(t, err) assert.Nil(t, gatewayJob) } + +func TestEncodeJobSig(t *testing.T) { + // Empty signatures occur in offchain mode (broadcaster has no Eth keystore). + // The encoded form must be empty so the orchestrator's hex.DecodeString + // succeeds and VerifySig short-circuits to true. + assert.Equal(t, "", encodeJobSig(nil)) + assert.Equal(t, "", encodeJobSig([]byte{})) + + // Non-empty signatures get the "0x" prefix. + sig := []byte{0xde, 0xad, 0xbe, 0xef} + assert.Equal(t, "0xdeadbeef", encodeJobSig(sig)) +} diff --git a/byoc/job_orchestrator.go b/byoc/job_orchestrator.go index 210474992e..4cb594d1ed 100644 --- a/byoc/job_orchestrator.go +++ b/byoc/job_orchestrator.go @@ -594,13 +594,13 @@ func (bso *BYOCOrchestratorServer) verifyTokenCreds(ctx context.Context, tokenCr return nil, err } - sigHex := jobSender.Sig - if len(jobSender.Sig) > 130 { - sigHex = jobSender.Sig[2:] - } + // Strip the "0x" prefix if present. Empty Sig is valid: the gateway sends + // it in offchain mode (broadcaster has no Eth keystore); VerifySig + // short-circuits to true in that mode. + sigHex := strings.TrimPrefix(jobSender.Sig, "0x") sigByte, err := hex.DecodeString(sigHex) if err != nil { - clog.Errorf(ctx, "Unable to hex-decode signature", err) + clog.Errorf(ctx, "Unable to hex-decode signature err=%v", err) return nil, errSegSig } diff --git a/byoc/job_orchestrator_test.go b/byoc/job_orchestrator_test.go index 55253d9669..c6042d7c92 100644 --- a/byoc/job_orchestrator_test.go +++ b/byoc/job_orchestrator_test.go @@ -969,3 +969,55 @@ func createMockJobToken(hostUrl string) *JobToken { AvailableCapacity: 1, } } + +func TestVerifyTokenCreds_OffchainEmptySig(t *testing.T) { + // In offchain mode the gateway sends an empty Sig (no keystore to sign + // with). The orchestrator's VerifySig short-circuits to true offchain, + // so the empty Sig must round-trip through hex.DecodeString without + // erroring. + mockJobOrch := newMockJobOrchestrator() + mockJobOrch.verifySignature = func(addr ethcommon.Address, msg string, sig []byte) bool { + return true + } + bso := &BYOCOrchestratorServer{ + node: mockJobLivepeerNode(), + orch: mockJobOrch, + } + + js := &JobSender{ + Addr: "0x0000000000000000000000000000000000000000", + Sig: "", + } + jsBytes, _ := json.Marshal(js) + creds := base64.StdEncoding.EncodeToString(jsBytes) + + got, err := bso.verifyTokenCreds(context.Background(), creds) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "", got.Sig) +} + +func TestVerifyTokenCreds_StripsHexPrefix(t *testing.T) { + // "0x"-prefixed signatures must be accepted regardless of length. + // (Pre-fix code only stripped the prefix when len > 130, breaking + // short or empty sigs.) + mockJobOrch := newMockJobOrchestrator() + mockJobOrch.verifySignature = func(addr ethcommon.Address, msg string, sig []byte) bool { + return true + } + bso := &BYOCOrchestratorServer{ + node: mockJobLivepeerNode(), + orch: mockJobOrch, + } + + js := &JobSender{ + Addr: "0x0000000000000000000000000000000000000000", + Sig: "0xdeadbeef", + } + jsBytes, _ := json.Marshal(js) + creds := base64.StdEncoding.EncodeToString(jsBytes) + + got, err := bso.verifyTokenCreds(context.Background(), creds) + assert.NoError(t, err) + assert.NotNil(t, got) +}