Skip to content

Commit dac48f3

Browse files
authored
SDP-1863: Implement SEP-45 challenge creation (#942)
### What This implements the SEP-45 challenge creation flow https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0045.md#challenge. ### Why The Anchor Platform has been removed, so we need to reimplement SEP-45. ### Known limitations The HTTP handler, dependency injection, and challenge validation will be implemented separately to keep the size of this PR reasonable. ### Checklist - [x] Title follows `SDP-1234: Add new feature` or `Chore: Refactor package xyz` format. The Jira ticket code was included if available. - [x] PR has a focused scope and doesn't mix features with refactoring - [x] Tests are included (if applicable) - [ ] `CHANGELOG.md` is updated (if applicable) - [ ] CONFIG/SECRETS changes are updated in helmcharts and deployments (if applicable) - [ ] Preview deployment works as expected - [ ] Ready for production
1 parent 2bfc055 commit dac48f3

File tree

7 files changed

+1178
-0
lines changed

7 files changed

+1178
-0
lines changed

internal/services/sep45_service.go

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
package services
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/base64"
7+
"encoding/binary"
8+
"fmt"
9+
"net/url"
10+
"strings"
11+
12+
"github.com/stellar/go/clients/stellartoml"
13+
"github.com/stellar/go/keypair"
14+
"github.com/stellar/go/strkey"
15+
"github.com/stellar/go/txnbuild"
16+
"github.com/stellar/go/xdr"
17+
"github.com/stellar/stellar-rpc/protocol"
18+
19+
"github.com/stellar/stellar-disbursement-platform-backend/internal/sdpcontext"
20+
"github.com/stellar/stellar-disbursement-platform-backend/internal/stellar"
21+
"github.com/stellar/stellar-disbursement-platform-backend/internal/utils"
22+
)
23+
24+
// The number of ledgers after which the server-signed authorization entry expires.
25+
const signatureExpirationLedgers = 10
26+
27+
//go:generate mockery --name=SEP45Service --case=underscore --structname=MockSEP45Service --filename=sep45_service_mock.go --inpackage
28+
type SEP45Service interface {
29+
// CreateChallenge creates a new challenge for the given contract account and home domain.
30+
CreateChallenge(ctx context.Context, req SEP45ChallengeRequest) (*SEP45ChallengeResponse, error)
31+
// ValidateChallenge validates the given challenge and returns a JWT if valid.
32+
ValidateChallenge(ctx context.Context, req SEP45ValidationRequest) (*SEP45ValidationResponse, error)
33+
}
34+
35+
type sep45Service struct {
36+
rpcClient stellar.RPCClient
37+
tomlClient stellartoml.ClientInterface
38+
networkPassphrase string
39+
contractID xdr.ContractId
40+
signingKP *keypair.Full
41+
signingPKBytes []byte
42+
clientAttributionRequired bool
43+
allowHTTPRetry bool
44+
baseURL string
45+
}
46+
47+
type SEP45ChallengeRequest struct {
48+
Account string `json:"account" query:"account"`
49+
HomeDomain string `json:"home_domain" query:"home_domain"`
50+
ClientDomain *string `json:"client_domain,omitempty" query:"client_domain"`
51+
}
52+
53+
func (r SEP45ChallengeRequest) Validate() error {
54+
if strings.TrimSpace(r.Account) == "" {
55+
return fmt.Errorf("account is required")
56+
}
57+
if !strkey.IsValidContractAddress(r.Account) {
58+
return fmt.Errorf("account must be a valid contract address")
59+
}
60+
if strings.TrimSpace(r.HomeDomain) == "" {
61+
return fmt.Errorf("home_domain is required")
62+
}
63+
return nil
64+
}
65+
66+
type SEP45ChallengeResponse struct {
67+
AuthorizationEntries string `json:"authorization_entries"`
68+
NetworkPassphrase string `json:"network_passphrase"`
69+
}
70+
71+
type SEP45ValidationRequest struct {
72+
AuthorizationEntries string `json:"authorization_entries" form:"authorization_entries"`
73+
}
74+
75+
type SEP45ValidationResponse struct {
76+
Token string `json:"token"`
77+
}
78+
79+
type SEP45ServiceOptions struct {
80+
RPCClient stellar.RPCClient
81+
TOMLClient stellartoml.ClientInterface
82+
NetworkPassphrase string
83+
WebAuthVerifyContractID string
84+
ServerSigningKeypair *keypair.Full
85+
BaseURL string
86+
ClientAttributionRequired bool
87+
AllowHTTPRetry bool
88+
}
89+
90+
func NewSEP45Service(opts SEP45ServiceOptions) (SEP45Service, error) {
91+
if opts.RPCClient == nil {
92+
return nil, fmt.Errorf("rpc client cannot be nil")
93+
}
94+
if strings.TrimSpace(opts.NetworkPassphrase) == "" {
95+
return nil, fmt.Errorf("network passphrase cannot be empty")
96+
}
97+
if strings.TrimSpace(opts.WebAuthVerifyContractID) == "" {
98+
return nil, fmt.Errorf("web_auth_verify contract ID cannot be empty")
99+
}
100+
if opts.ServerSigningKeypair == nil {
101+
return nil, fmt.Errorf("server signing keypair cannot be nil")
102+
}
103+
if strings.TrimSpace(opts.BaseURL) == "" {
104+
return nil, fmt.Errorf("base URL cannot be empty")
105+
}
106+
107+
signingKP := opts.ServerSigningKeypair
108+
signingPKBytes, err := strkey.Decode(strkey.VersionByteAccountID, signingKP.Address())
109+
if err != nil {
110+
return nil, fmt.Errorf("decoding signing public key: %w", err)
111+
}
112+
113+
rawContractID, err := strkey.Decode(strkey.VersionByteContract, opts.WebAuthVerifyContractID)
114+
if err != nil {
115+
return nil, fmt.Errorf("decoding contract ID: %w", err)
116+
}
117+
var contractID xdr.ContractId
118+
copy(contractID[:], rawContractID)
119+
120+
tomlClient := opts.TOMLClient
121+
if tomlClient == nil {
122+
tomlClient = stellartoml.DefaultClient
123+
}
124+
125+
return &sep45Service{
126+
rpcClient: opts.RPCClient,
127+
tomlClient: tomlClient,
128+
networkPassphrase: opts.NetworkPassphrase,
129+
contractID: contractID,
130+
signingKP: signingKP,
131+
signingPKBytes: signingPKBytes,
132+
clientAttributionRequired: opts.ClientAttributionRequired,
133+
allowHTTPRetry: opts.AllowHTTPRetry,
134+
baseURL: opts.BaseURL,
135+
}, nil
136+
}
137+
138+
func (s *sep45Service) CreateChallenge(ctx context.Context, req SEP45ChallengeRequest) (*SEP45ChallengeResponse, error) {
139+
if err := req.Validate(); err != nil {
140+
return nil, err
141+
}
142+
143+
webAuthDomain := s.getWebAuthDomain(ctx)
144+
if strings.TrimSpace(webAuthDomain) == "" {
145+
return nil, fmt.Errorf("unable to determine web_auth_domain")
146+
}
147+
148+
account := strings.TrimSpace(req.Account)
149+
homeDomain := strings.TrimSpace(req.HomeDomain)
150+
if homeDomain == "" {
151+
return nil, fmt.Errorf("home_domain is required")
152+
}
153+
154+
if !s.isValidHomeDomain(homeDomain) {
155+
return nil, fmt.Errorf("invalid home_domain must match %s", s.getBaseDomain())
156+
}
157+
158+
clientDomain := ""
159+
if req.ClientDomain != nil {
160+
clientDomain = strings.TrimSpace(*req.ClientDomain)
161+
}
162+
if s.clientAttributionRequired && clientDomain == "" {
163+
return nil, fmt.Errorf("client_domain is required")
164+
}
165+
166+
var clientDomainAccount string
167+
if clientDomain != "" {
168+
key, err := s.fetchSigningKeyFromClientDomain(clientDomain)
169+
if err != nil {
170+
return nil, fmt.Errorf("fetching signing key for client_domain %s: %w", clientDomain, err)
171+
}
172+
clientDomainAccount = key
173+
}
174+
175+
// TODO(philip): We generate a random nonce right now and don't store it anywhere.
176+
// This is also the case with the SEP-10 implementation, so we should address them together.
177+
nonce, err := generateNonce()
178+
if err != nil {
179+
return nil, fmt.Errorf("generating nonce: %w", err)
180+
}
181+
182+
// Build the invocation arguments for the web_auth_verify contract function, ensuring
183+
// that fields are in lexicographical order.
184+
fields := []xdr.ScMapEntry{
185+
utils.NewSymbolStringEntry("account", account),
186+
}
187+
if clientDomain != "" {
188+
fields = append(fields,
189+
utils.NewSymbolStringEntry("client_domain", clientDomain),
190+
utils.NewSymbolStringEntry("client_domain_account", clientDomainAccount),
191+
)
192+
}
193+
fields = append(fields,
194+
utils.NewSymbolStringEntry("home_domain", homeDomain),
195+
utils.NewSymbolStringEntry("nonce", nonce),
196+
utils.NewSymbolStringEntry("web_auth_domain", webAuthDomain),
197+
utils.NewSymbolStringEntry("web_auth_domain_account", s.signingKP.Address()),
198+
)
199+
200+
scMap := xdr.ScMap(fields)
201+
arg, err := xdr.NewScVal(xdr.ScValTypeScvMap, &scMap)
202+
if err != nil {
203+
return nil, fmt.Errorf("building invocation arguments: %w", err)
204+
}
205+
args := xdr.ScVec{arg}
206+
207+
hostFunction := xdr.HostFunction{
208+
Type: xdr.HostFunctionTypeHostFunctionTypeInvokeContract,
209+
InvokeContract: &xdr.InvokeContractArgs{
210+
ContractAddress: xdr.ScAddress{
211+
Type: xdr.ScAddressTypeScAddressTypeContract,
212+
ContractId: &s.contractID,
213+
},
214+
FunctionName: "web_auth_verify",
215+
Args: args,
216+
},
217+
}
218+
219+
txParams := txnbuild.TransactionParams{
220+
// The challenge transaction's source account must be different than the server signing account
221+
// so that there is an authorization entry generated for the server signing account.
222+
SourceAccount: &txnbuild.SimpleAccount{
223+
AccountID: keypair.MustRandom().Address(),
224+
Sequence: 0,
225+
},
226+
BaseFee: int64(txnbuild.MinBaseFee),
227+
Preconditions: txnbuild.Preconditions{
228+
TimeBounds: txnbuild.NewTimeout(300),
229+
},
230+
Operations: []txnbuild.Operation{&txnbuild.InvokeHostFunction{
231+
SourceAccount: s.signingKP.Address(),
232+
HostFunction: hostFunction,
233+
}},
234+
}
235+
236+
tx, err := txnbuild.NewTransaction(txParams)
237+
if err != nil {
238+
return nil, fmt.Errorf("building transaction: %w", err)
239+
}
240+
241+
base64EncodedTx, err := tx.Base64()
242+
if err != nil {
243+
return nil, fmt.Errorf("encoding transaction: %w", err)
244+
}
245+
246+
// Simulate the transaction to obtain the authorization entries.
247+
//
248+
// There should be an entry for:
249+
// 1. The server signing account.
250+
// 2. The client contract account (corresponding to the `account` argument).
251+
// 3. The client domain account (if applicable).
252+
simResult, simErr := s.rpcClient.SimulateTransaction(ctx, protocol.SimulateTransactionRequest{
253+
Transaction: base64EncodedTx,
254+
})
255+
if simErr != nil {
256+
return nil, fmt.Errorf("simulating transaction: %w", simErr)
257+
}
258+
259+
authEntries, err := s.signServerAuthEntry(ctx, simResult)
260+
if err != nil {
261+
return nil, err
262+
}
263+
264+
rawEntries, err := authEntries.MarshalBinary()
265+
if err != nil {
266+
return nil, fmt.Errorf("encoding authorization entries: %w", err)
267+
}
268+
269+
return &SEP45ChallengeResponse{
270+
AuthorizationEntries: base64.StdEncoding.EncodeToString(rawEntries),
271+
NetworkPassphrase: s.networkPassphrase,
272+
}, nil
273+
}
274+
275+
func (s *sep45Service) ValidateChallenge(ctx context.Context, req SEP45ValidationRequest) (*SEP45ValidationResponse, error) {
276+
return nil, fmt.Errorf("challenge validation is not implemented")
277+
}
278+
279+
func (s *sep45Service) signServerAuthEntry(ctx context.Context, result *stellar.SimulationResult) (xdr.SorobanAuthorizationEntries, error) {
280+
if result == nil || len(result.Response.Results) == 0 {
281+
return nil, fmt.Errorf("missing simulation results")
282+
}
283+
authXDR := result.Response.Results[0].AuthXDR
284+
if authXDR == nil {
285+
return nil, fmt.Errorf("missing authorization entries")
286+
}
287+
288+
ledgerNumber, err := s.rpcClient.GetLatestLedgerSequence(ctx)
289+
if err != nil {
290+
return nil, fmt.Errorf("fetching latest ledger: %w", err)
291+
}
292+
validUntil := ledgerNumber + uint32(signatureExpirationLedgers)
293+
294+
signedEntries := make(xdr.SorobanAuthorizationEntries, 0, len(*authXDR))
295+
for _, entryB64 := range *authXDR {
296+
var entry xdr.SorobanAuthorizationEntry
297+
if err := xdr.SafeUnmarshalBase64(entryB64, &entry); err != nil {
298+
return nil, fmt.Errorf("unmarshalling authorization entry: %w", err)
299+
}
300+
301+
signedEntry, err := utils.SignAuthEntry(entry, validUntil, s.signingKP, s.networkPassphrase)
302+
if err != nil {
303+
return nil, fmt.Errorf("signing authorization entry: %w", err)
304+
}
305+
signedEntries = append(signedEntries, signedEntry)
306+
}
307+
308+
return signedEntries, nil
309+
}
310+
311+
func (s *sep45Service) fetchSigningKeyFromClientDomain(clientDomain string) (string, error) {
312+
resp, err := s.tomlClient.GetStellarToml(clientDomain)
313+
if err != nil && s.allowHTTPRetry {
314+
if client, ok := s.tomlClient.(*stellartoml.Client); ok {
315+
fallback := *client
316+
fallback.UseHTTP = true
317+
resp, err = fallback.GetStellarToml(clientDomain)
318+
} else {
319+
fallback := &stellartoml.Client{UseHTTP: true}
320+
resp, err = fallback.GetStellarToml(clientDomain)
321+
}
322+
}
323+
if err != nil {
324+
return "", fmt.Errorf("fetching stellar.toml for %s: %w", clientDomain, err)
325+
}
326+
if resp == nil || strings.TrimSpace(resp.SigningKey) == "" {
327+
return "", fmt.Errorf("stellar.toml at %s missing SIGNING_KEY", clientDomain)
328+
}
329+
if !strkey.IsValidEd25519PublicKey(resp.SigningKey) {
330+
return "", fmt.Errorf("stellar.toml SIGNING_KEY at %s is invalid", clientDomain)
331+
}
332+
return resp.SigningKey, nil
333+
}
334+
335+
func generateNonce() (string, error) {
336+
var buf [4]byte
337+
if _, err := rand.Read(buf[:]); err != nil {
338+
return "", fmt.Errorf("generating nonce: %w", err)
339+
}
340+
return fmt.Sprintf("%d", binary.BigEndian.Uint32(buf[:])), nil
341+
}
342+
343+
// TODO(philip): Below methods are shared with sep10_service.go so they can be moved to a common utility package later.
344+
345+
func (s *sep45Service) getWebAuthDomain(ctx context.Context) string {
346+
currentTenant, err := sdpcontext.GetTenantFromContext(ctx)
347+
if err == nil && currentTenant != nil && currentTenant.BaseURL != nil {
348+
parsedURL, parseErr := url.Parse(*currentTenant.BaseURL)
349+
if parseErr == nil {
350+
return parsedURL.Host
351+
}
352+
}
353+
return s.getBaseDomain()
354+
}
355+
356+
func (s *sep45Service) getBaseDomain() string {
357+
parsed, err := url.Parse(s.baseURL)
358+
if err != nil {
359+
return ""
360+
}
361+
return parsed.Host
362+
}
363+
364+
func (s *sep45Service) isValidHomeDomain(homeDomain string) bool {
365+
baseDomain := s.getBaseDomain()
366+
if baseDomain == "" || homeDomain == "" {
367+
return false
368+
}
369+
370+
baseDomainLower := strings.ToLower(baseDomain)
371+
homeDomainLower := strings.ToLower(homeDomain)
372+
373+
if homeDomainLower == baseDomainLower {
374+
return true
375+
}
376+
377+
return strings.HasSuffix(homeDomainLower, "."+baseDomainLower)
378+
}

0 commit comments

Comments
 (0)