Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions pkg/covenantsigner/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ type Transition struct {
// underlying signing job. The service treats this as a terminal failure and
// transitions the job to JobStateFailed.
//
// An Engine may also implement SignerApprovalVerifier and
// CurrentBlockHeightProvider; see their doc comments for the constraints
// between them.
// An Engine may also implement SignerApprovalVerifier,
// CurrentBlockHeightProvider, and SignerApprovalCertificateIssuer; see their
// doc comments for the constraints between them.
type Engine interface {
OnSubmit(ctx context.Context, job *Job) (*Transition, error)
OnPoll(ctx context.Context, job *Job) (*Transition, error)
Expand Down Expand Up @@ -70,6 +70,31 @@ func (savf SignerApprovalVerifierFunc) VerifySignerApproval(
return savf(request)
}

// SignerApprovalCertificateIssuer is implemented by Engines that can produce a
// v2 SignerApprovalCertificate for a wallet they control by running a threshold
// signing round over the certificate digest. The admin HTTP issuer endpoint
// type-asserts the engine to this interface; engines that omit it return 501.
//
// approvalDigest must be the 32-byte artifact-approval payload digest that the
// eventual Submit request will carry — VerifySignerApproval rejects certificates
// whose ApprovalDigest does not match request.artifactApprovals.payload.
// endBlock is a host-chain (e.g. Ethereum) height after which the certificate
// must be rejected as expired.
type SignerApprovalCertificateIssuer interface {
IssueSignerApprovalCertificate(
ctx context.Context,
walletPublicKeyHash [20]byte,
approvalDigest []byte,
endBlock uint64,
) (*SignerApprovalCertificate, error)
}

// ErrSignerApprovalCertificateIssuerUnsupported is returned when the configured
// engine does not implement SignerApprovalCertificateIssuer.
var ErrSignerApprovalCertificateIssuerUnsupported = errors.New(
"covenant signer engine does not support signer approval certificate issuance",
)

type passiveEngine struct{}

func NewPassiveEngine() Engine {
Expand Down
39 changes: 39 additions & 0 deletions pkg/covenantsigner/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ func newHandler(service *Service, serviceCtx context.Context, authToken string,
_, _ = w.Write([]byte(`{"status":"ok"}`))
})

mux.HandleFunc(
"POST /v1/admin/signer-approval-certificates",
issueSignerApprovalCertificateHandler(service, serviceCtx, submitLimiter),
)
mux.HandleFunc("POST /v1/qc_v1/signer/requests", submitHandler(service, serviceCtx, TemplateQcV1, submitLimiter))
mux.HandleFunc("POST /v1/qc_v1/signer/requests:poll", pollBodyHandler(service, TemplateQcV1, pollLimiter))
mux.HandleFunc("/v1/qc_v1/signer/requests/", pollPathHandler(service, TemplateQcV1, pollLimiter))
Expand Down Expand Up @@ -431,6 +435,10 @@ func handleError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if errors.Is(err, ErrSignerApprovalCertificateIssuerUnsupported) {
http.Error(w, err.Error(), http.StatusNotImplemented)
return
}

logger.Errorf("covenant signer request failed: [%v]", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
Expand Down Expand Up @@ -479,6 +487,37 @@ func submitHandler(service *Service, serviceCtx context.Context, route TemplateI
}
}

func issueSignerApprovalCertificateHandler(
service *Service,
serviceCtx context.Context,
limiter *rate.Limiter,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}

input := IssueSignerApprovalCertificateInput{}
if !decodeJSON(w, r, &input) {
return
}

// Certificate issuance runs a full threshold signing round, so use the
// same service-level timeout and detached context as submit.
issueCtx, cancelIssue := context.WithTimeout(serviceCtx, submitTimeout)
defer cancelIssue()

certificate, err := service.IssueSignerApprovalCertificate(issueCtx, input)
if err != nil {
handleError(w, err)
return
}

writeJSON(w, http.StatusOK, certificate)
}
}

func pollBodyHandler(service *Service, route TemplateID, limiter *rate.Limiter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
Expand Down
277 changes: 277 additions & 0 deletions pkg/covenantsigner/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package covenantsigner
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -1080,3 +1081,279 @@ func TestSubmitHandlerPreservesServiceContextValues(t *testing.T) {
)
}
}

type scriptedIssuerEngine struct {
scriptedEngine
issue func(
ctx context.Context,
walletPublicKeyHash [20]byte,
approvalDigest []byte,
endBlock uint64,
) (*SignerApprovalCertificate, error)
}

func (sie *scriptedIssuerEngine) IssueSignerApprovalCertificate(
ctx context.Context,
walletPublicKeyHash [20]byte,
approvalDigest []byte,
endBlock uint64,
) (*SignerApprovalCertificate, error) {
if sie.issue == nil {
return nil, fmt.Errorf("issue function not configured")
}
return sie.issue(ctx, walletPublicKeyHash, approvalDigest, endBlock)
}

func TestServerIssuesSignerApprovalCertificate(t *testing.T) {
endBlock := uint64(12345678)
expected := &SignerApprovalCertificate{
CertificateVersion: 2,
SignatureAlgorithm: "tecdsa-secp256k1",
ApprovalDigest: "0x" + strings.Repeat("11", 32),
WalletPublicKey: "0x04" + strings.Repeat("22", 64),
SignerSetHash: "0x" + strings.Repeat("33", 32),
Signature: "0x30" + strings.Repeat("44", 32),
EndBlock: &endBlock,
}

handle := newMemoryHandle()
service, err := NewService(handle, &scriptedIssuerEngine{
issue: func(
_ context.Context,
walletPublicKeyHash [20]byte,
approvalDigest []byte,
gotEndBlock uint64,
) (*SignerApprovalCertificate, error) {
if gotEndBlock != endBlock {
t.Fatalf("unexpected endBlock: %d", gotEndBlock)
}
if hex.EncodeToString(walletPublicKeyHash[:]) != strings.Repeat("12", 20) {
t.Fatalf("unexpected wallet pkh: %x", walletPublicKeyHash)
}
if hex.EncodeToString(approvalDigest) != strings.Repeat("11", 32) {
t.Fatalf("unexpected approval digest: %x", approvalDigest)
}
return expected, nil
},
})
if err != nil {
t.Fatal(err)
}

server := httptest.NewServer(newHandler(service, context.Background(), "", true))
defer server.Close()

payload := mustJSON(t, IssueSignerApprovalCertificateInput{
WalletPublicKeyHash: "0x" + strings.Repeat("12", 20),
ApprovalDigest: "0x" + strings.Repeat("11", 32),
EndBlock: endBlock,
})

response, err := http.Post(
server.URL+"/v1/admin/signer-approval-certificates",
"application/json",
bytes.NewReader(payload),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
t.Fatalf("unexpected status: %d %s", response.StatusCode, string(body))
}

var got SignerApprovalCertificate
if err := json.NewDecoder(response.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.ApprovalDigest != expected.ApprovalDigest {
t.Fatalf("unexpected certificate: %+v", got)
}
}

func TestServerReturns501WhenEngineLacksCertificateIssuer(t *testing.T) {
handle := newMemoryHandle()
service, err := NewService(handle, &scriptedEngine{})
if err != nil {
t.Fatal(err)
}

server := httptest.NewServer(newHandler(service, context.Background(), "", true))
defer server.Close()

payload := mustJSON(t, IssueSignerApprovalCertificateInput{
WalletPublicKeyHash: "0x" + strings.Repeat("12", 20),
ApprovalDigest: "0x" + strings.Repeat("11", 32),
EndBlock: 12345678,
})

response, err := http.Post(
server.URL+"/v1/admin/signer-approval-certificates",
"application/json",
bytes.NewReader(payload),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()

if response.StatusCode != http.StatusNotImplemented {
body, _ := io.ReadAll(response.Body)
t.Fatalf("expected 501, got %d %s", response.StatusCode, string(body))
}
}

func TestServerRequiresBearerTokenForCertificateIssuer(t *testing.T) {
handle := newMemoryHandle()
service, err := NewService(handle, &scriptedIssuerEngine{
issue: func(
context.Context,
[20]byte,
[]byte,
uint64,
) (*SignerApprovalCertificate, error) {
endBlock := uint64(1)
return &SignerApprovalCertificate{EndBlock: &endBlock}, nil
},
})
if err != nil {
t.Fatal(err)
}

server := httptest.NewServer(newHandler(service, context.Background(), "test-token", true))
defer server.Close()

payload := mustJSON(t, IssueSignerApprovalCertificateInput{
WalletPublicKeyHash: "0x" + strings.Repeat("12", 20),
ApprovalDigest: "0x" + strings.Repeat("11", 32),
EndBlock: 12345678,
})

unauthorized, err := http.NewRequest(
http.MethodPost,
server.URL+"/v1/admin/signer-approval-certificates",
bytes.NewReader(payload),
)
if err != nil {
t.Fatal(err)
}
unauthorized.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(unauthorized)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
body, _ := io.ReadAll(response.Body)
t.Fatalf("expected 401, got %d %s", response.StatusCode, string(body))
}

authorized, err := http.NewRequest(
http.MethodPost,
server.URL+"/v1/admin/signer-approval-certificates",
bytes.NewReader(payload),
)
if err != nil {
t.Fatal(err)
}
authorized.Header.Set("Content-Type", "application/json")
authorized.Header.Set("Authorization", "Bearer test-token")

authorizedResponse, err := http.DefaultClient.Do(authorized)
if err != nil {
t.Fatal(err)
}
defer authorizedResponse.Body.Close()
if authorizedResponse.StatusCode != http.StatusOK {
body, _ := io.ReadAll(authorizedResponse.Body)
t.Fatalf("expected 200, got %d %s", authorizedResponse.StatusCode, string(body))
}
}

func TestServerRejectsUnknownFieldsOnCertificateIssuer(t *testing.T) {
handle := newMemoryHandle()
service, err := NewService(handle, &scriptedIssuerEngine{
issue: func(
context.Context,
[20]byte,
[]byte,
uint64,
) (*SignerApprovalCertificate, error) {
t.Fatal("issuer should not be called for malformed bodies")
return nil, nil
},
})
if err != nil {
t.Fatal(err)
}

server := httptest.NewServer(newHandler(service, context.Background(), "", true))
defer server.Close()

payload := []byte(`{
"walletPublicKeyHash":"0x` + strings.Repeat("12", 20) + `",
"approvalDigest":"0x` + strings.Repeat("11", 32) + `",
"endBlock":12345678,
"unexpected":true
}`)

response, err := http.Post(
server.URL+"/v1/admin/signer-approval-certificates",
"application/json",
bytes.NewReader(payload),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()

if response.StatusCode != http.StatusBadRequest {
body, _ := io.ReadAll(response.Body)
t.Fatalf("expected 400, got %d %s", response.StatusCode, string(body))
}
}

func TestServerRejectsInvalidCertificateIssuerInput(t *testing.T) {
handle := newMemoryHandle()
service, err := NewService(handle, &scriptedIssuerEngine{
issue: func(
context.Context,
[20]byte,
[]byte,
uint64,
) (*SignerApprovalCertificate, error) {
t.Fatal("issuer should not be called for invalid input")
return nil, nil
},
})
if err != nil {
t.Fatal(err)
}

server := httptest.NewServer(newHandler(service, context.Background(), "", true))
defer server.Close()

payload := mustJSON(t, IssueSignerApprovalCertificateInput{
WalletPublicKeyHash: "0x" + strings.Repeat("12", 20),
ApprovalDigest: "0x" + strings.Repeat("11", 32),
EndBlock: 0,
})

response, err := http.Post(
server.URL+"/v1/admin/signer-approval-certificates",
"application/json",
bytes.NewReader(payload),
)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()

if response.StatusCode != http.StatusBadRequest {
body, _ := io.ReadAll(response.Body)
t.Fatalf("expected 400, got %d %s", response.StatusCode, string(body))
}
}
Loading
Loading