Skip to content

Commit 062c73c

Browse files
committed
Implement SEP-45 challenge validation
1 parent dac48f3 commit 062c73c

File tree

4 files changed

+906
-11
lines changed

4 files changed

+906
-11
lines changed

internal/services/sep45_service.go

Lines changed: 335 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package services
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/rand"
67
"encoding/base64"
@@ -272,8 +273,160 @@ func (s *sep45Service) CreateChallenge(ctx context.Context, req SEP45ChallengeRe
272273
}, nil
273274
}
274275

276+
type webAuthVerifyArgs struct {
277+
raw map[string]string // Useful for comparing arguments
278+
clientAccount string
279+
clientContractID xdr.ContractId
280+
homeDomain string
281+
clientDomain string
282+
clientDomainAccount string
283+
}
284+
275285
func (s *sep45Service) ValidateChallenge(ctx context.Context, req SEP45ValidationRequest) (*SEP45ValidationResponse, error) {
276-
return nil, fmt.Errorf("challenge validation is not implemented")
286+
encodedEntries := strings.TrimSpace(req.AuthorizationEntries)
287+
if encodedEntries == "" {
288+
return nil, fmt.Errorf("authorization_entries is required")
289+
}
290+
291+
rawEntries, err := base64.StdEncoding.DecodeString(encodedEntries)
292+
if err != nil {
293+
return nil, fmt.Errorf("decoding authorization entries: %w", err)
294+
}
295+
296+
var entries xdr.SorobanAuthorizationEntries
297+
if err := entries.UnmarshalBinary(rawEntries); err != nil {
298+
return nil, fmt.Errorf("unmarshalling authorization entries: %w", err)
299+
}
300+
if len(entries) == 0 {
301+
return nil, fmt.Errorf("authorization entries cannot be empty")
302+
}
303+
304+
webAuthDomain := strings.TrimSpace(s.getWebAuthDomain(ctx))
305+
if webAuthDomain == "" {
306+
return nil, fmt.Errorf("unable to determine web_auth_domain")
307+
}
308+
309+
var (
310+
argsXDR xdr.ScVec
311+
parsedArgs *webAuthVerifyArgs
312+
serverEntryVerified bool
313+
clientEntryFound bool
314+
clientDomainFound bool
315+
)
316+
317+
for _, entry := range entries {
318+
contractFn, err := s.ensureWebAuthInvocation(entry)
319+
if err != nil {
320+
return nil, err
321+
}
322+
323+
// Extract the invocation arguments and make sure they are valid and consistent across entries
324+
argsMap, err := utils.ExtractArgsMap(contractFn.Args)
325+
if err != nil {
326+
return nil, fmt.Errorf("extracting authorization arguments: %w", err)
327+
}
328+
if parsedArgs == nil {
329+
argsXDR = contractFn.Args
330+
parsedArgs, err = s.buildChallengeArgs(argsMap, webAuthDomain)
331+
if err != nil {
332+
return nil, err
333+
}
334+
} else if err := compareArgs(argsMap, parsedArgs.raw); err != nil {
335+
return nil, err
336+
}
337+
338+
// Check that we have the expected authorization entries
339+
addr := entry.Credentials.Address.Address
340+
switch addr.Type {
341+
case xdr.ScAddressTypeScAddressTypeAccount:
342+
if addr.AccountId == nil {
343+
return nil, fmt.Errorf("authorization entry missing account id")
344+
}
345+
// If the account matches the server signing key, we can verify the signature now
346+
accountAddress := addr.AccountId.Address()
347+
if accountAddress == s.signingKP.Address() {
348+
if err := s.verifyServerAuthEntry(entry); err != nil {
349+
return nil, err
350+
}
351+
serverEntryVerified = true
352+
} else if parsedArgs != nil && parsedArgs.clientDomainAccount != "" && accountAddress == parsedArgs.clientDomainAccount {
353+
clientDomainFound = true
354+
}
355+
case xdr.ScAddressTypeScAddressTypeContract:
356+
if addr.ContractId == nil {
357+
return nil, fmt.Errorf("authorization entry missing contract id")
358+
}
359+
if parsedArgs != nil && *addr.ContractId == parsedArgs.clientContractID {
360+
clientEntryFound = true
361+
}
362+
default:
363+
return nil, fmt.Errorf("unsupported authorization address type: %d", addr.Type)
364+
}
365+
}
366+
367+
if parsedArgs == nil {
368+
return nil, fmt.Errorf("missing authorization arguments")
369+
}
370+
if !serverEntryVerified {
371+
return nil, fmt.Errorf("missing signed server authorization entry")
372+
}
373+
if !clientEntryFound {
374+
return nil, fmt.Errorf("missing client account authorization entry")
375+
}
376+
if parsedArgs.clientDomainAccount != "" && !clientDomainFound {
377+
return nil, fmt.Errorf("missing client domain authorization entry")
378+
}
379+
if len(argsXDR) == 0 {
380+
return nil, fmt.Errorf("unable to rebuild invocation arguments")
381+
}
382+
383+
contractID := s.contractID
384+
hostFunction := xdr.HostFunction{
385+
Type: xdr.HostFunctionTypeHostFunctionTypeInvokeContract,
386+
InvokeContract: &xdr.InvokeContractArgs{
387+
ContractAddress: xdr.ScAddress{
388+
Type: xdr.ScAddressTypeScAddressTypeContract,
389+
ContractId: &contractID,
390+
},
391+
FunctionName: "web_auth_verify",
392+
Args: argsXDR,
393+
},
394+
}
395+
396+
authEntries := make([]xdr.SorobanAuthorizationEntry, len(entries))
397+
copy(authEntries, entries)
398+
399+
txParams := txnbuild.TransactionParams{
400+
SourceAccount: &txnbuild.SimpleAccount{
401+
AccountID: keypair.MustRandom().Address(),
402+
Sequence: 0,
403+
},
404+
BaseFee: int64(txnbuild.MinBaseFee),
405+
Preconditions: txnbuild.Preconditions{
406+
TimeBounds: txnbuild.NewTimeout(300),
407+
},
408+
Operations: []txnbuild.Operation{&txnbuild.InvokeHostFunction{
409+
SourceAccount: s.signingKP.Address(),
410+
HostFunction: hostFunction,
411+
Auth: authEntries,
412+
}},
413+
}
414+
415+
tx, err := txnbuild.NewTransaction(txParams)
416+
if err != nil {
417+
return nil, fmt.Errorf("building transaction: %w", err)
418+
}
419+
420+
txB64, err := tx.Base64()
421+
if err != nil {
422+
return nil, fmt.Errorf("encoding transaction: %w", err)
423+
}
424+
425+
if _, simErr := s.rpcClient.SimulateTransaction(ctx, protocol.SimulateTransactionRequest{Transaction: txB64}); simErr != nil {
426+
return nil, fmt.Errorf("simulating transaction: %w", simErr)
427+
}
428+
429+
return nil, fmt.Errorf("sep45 jwt generation not implemented")
277430
}
278431

279432
func (s *sep45Service) signServerAuthEntry(ctx context.Context, result *stellar.SimulationResult) (xdr.SorobanAuthorizationEntries, error) {
@@ -340,6 +493,187 @@ func generateNonce() (string, error) {
340493
return fmt.Sprintf("%d", binary.BigEndian.Uint32(buf[:])), nil
341494
}
342495

496+
func (s *sep45Service) ensureWebAuthInvocation(entry xdr.SorobanAuthorizationEntry) (*xdr.InvokeContractArgs, error) {
497+
if entry.Credentials.Type != xdr.SorobanCredentialsTypeSorobanCredentialsAddress || entry.Credentials.Address == nil {
498+
return nil, fmt.Errorf("authorization entry missing address credentials")
499+
}
500+
if len(entry.RootInvocation.SubInvocations) > 0 {
501+
return nil, fmt.Errorf("authorization entries cannot contain sub-invocations")
502+
}
503+
if entry.RootInvocation.Function.Type != xdr.SorobanAuthorizedFunctionTypeSorobanAuthorizedFunctionTypeContractFn || entry.RootInvocation.Function.ContractFn == nil {
504+
return nil, fmt.Errorf("authorization entry must invoke contract function")
505+
}
506+
507+
contractFn := entry.RootInvocation.Function.ContractFn
508+
if contractFn.ContractAddress.Type != xdr.ScAddressTypeScAddressTypeContract || contractFn.ContractAddress.ContractId == nil {
509+
return nil, fmt.Errorf("authorization entry missing contract address")
510+
}
511+
if *contractFn.ContractAddress.ContractId != s.contractID {
512+
return nil, fmt.Errorf("authorization entry targets unexpected contract")
513+
}
514+
if contractFn.FunctionName != "web_auth_verify" {
515+
return nil, fmt.Errorf("authorization entry must call web_auth_verify")
516+
}
517+
return contractFn, nil
518+
}
519+
520+
func compareArgs(current, expected map[string]string) error {
521+
if len(current) != len(expected) {
522+
return fmt.Errorf("authorization entry arguments mismatch")
523+
}
524+
for k, v := range expected {
525+
if current[k] != v {
526+
return fmt.Errorf("authorization entry arguments mismatch")
527+
}
528+
}
529+
return nil
530+
}
531+
532+
func (s *sep45Service) buildChallengeArgs(args map[string]string, webAuthDomain string) (*webAuthVerifyArgs, error) {
533+
clientAccount := strings.TrimSpace(args["account"])
534+
if clientAccount == "" {
535+
return nil, fmt.Errorf("account argument is required")
536+
}
537+
rawContractID, err := strkey.Decode(strkey.VersionByteContract, clientAccount)
538+
if err != nil {
539+
return nil, fmt.Errorf("account must be a valid contract address: %w", err)
540+
}
541+
var contractID xdr.ContractId
542+
copy(contractID[:], rawContractID)
543+
544+
homeDomain := strings.TrimSpace(args["home_domain"])
545+
if homeDomain == "" {
546+
return nil, fmt.Errorf("home_domain is required")
547+
}
548+
if !s.isValidHomeDomain(homeDomain) {
549+
return nil, fmt.Errorf("invalid home_domain must match %s", s.getBaseDomain())
550+
}
551+
552+
challengeWebAuthDomain := strings.TrimSpace(args["web_auth_domain"])
553+
if challengeWebAuthDomain == "" {
554+
return nil, fmt.Errorf("web_auth_domain is required")
555+
}
556+
if !strings.EqualFold(challengeWebAuthDomain, webAuthDomain) {
557+
return nil, fmt.Errorf("web_auth_domain must equal %s", webAuthDomain)
558+
}
559+
560+
webAuthDomainAccount := strings.TrimSpace(args["web_auth_domain_account"])
561+
if webAuthDomainAccount == "" {
562+
return nil, fmt.Errorf("web_auth_domain_account is required")
563+
}
564+
if !strkey.IsValidEd25519PublicKey(webAuthDomainAccount) {
565+
return nil, fmt.Errorf("web_auth_domain_account must be a valid Stellar account")
566+
}
567+
if webAuthDomainAccount != s.signingKP.Address() {
568+
return nil, fmt.Errorf("web_auth_domain_account must match server signing key")
569+
}
570+
571+
clientDomain := strings.TrimSpace(args["client_domain"])
572+
clientDomainAccount := strings.TrimSpace(args["client_domain_account"])
573+
if clientDomainAccount != "" && clientDomain == "" {
574+
return nil, fmt.Errorf("client_domain is required when client_domain_account is provided")
575+
}
576+
if clientDomain != "" {
577+
if clientDomainAccount == "" {
578+
return nil, fmt.Errorf("client_domain_account is required when client_domain is provided")
579+
}
580+
if !strkey.IsValidEd25519PublicKey(clientDomainAccount) {
581+
return nil, fmt.Errorf("client_domain_account must be a valid Stellar account")
582+
}
583+
} else if s.clientAttributionRequired {
584+
return nil, fmt.Errorf("client_domain is required")
585+
}
586+
587+
if strings.TrimSpace(args["nonce"]) == "" {
588+
return nil, fmt.Errorf("nonce is required")
589+
}
590+
591+
return &webAuthVerifyArgs{
592+
raw: args,
593+
clientAccount: clientAccount,
594+
clientContractID: contractID,
595+
homeDomain: homeDomain,
596+
clientDomain: clientDomain,
597+
clientDomainAccount: clientDomainAccount,
598+
}, nil
599+
}
600+
601+
func (s *sep45Service) verifyServerAuthEntry(entry xdr.SorobanAuthorizationEntry) error {
602+
if entry.Credentials.Address == nil {
603+
return fmt.Errorf("server authorization entry missing address credentials")
604+
}
605+
sigVal := entry.Credentials.Address.Signature
606+
if sigVal.Type != xdr.ScValTypeScvVec {
607+
return fmt.Errorf("server authorization entry missing signature")
608+
}
609+
expiration := uint32(entry.Credentials.Address.SignatureExpirationLedger)
610+
if expiration == 0 {
611+
return fmt.Errorf("server authorization entry missing expiration ledger")
612+
}
613+
614+
publicKey, signature, err := extractSignature(&sigVal)
615+
if err != nil {
616+
return err
617+
}
618+
if !bytes.Equal(publicKey, s.signingPKBytes) {
619+
return fmt.Errorf("server authorization entry signed by unexpected key")
620+
}
621+
622+
payload, err := utils.BuildAuthorizationPayload(entry, s.networkPassphrase)
623+
if err != nil {
624+
return fmt.Errorf("building authorization payload: %w", err)
625+
}
626+
627+
// We could also verify that the signature expiration ledger is not
628+
// expired yet so we can return early but this is also checked during the transaction simulation
629+
if err := s.signingKP.Verify(payload[:], signature); err != nil {
630+
return fmt.Errorf("server authorization entry signature invalid: %w", err)
631+
}
632+
return nil
633+
}
634+
635+
func extractSignature(sigVal *xdr.ScVal) ([]byte, []byte, error) {
636+
vec, ok := sigVal.GetVec()
637+
if !ok || vec == nil || len(*vec) == 0 {
638+
return nil, nil, fmt.Errorf("signature must be a vector")
639+
}
640+
sigMapVal := (*vec)[0]
641+
entries, ok := sigMapVal.GetMap()
642+
if !ok || entries == nil {
643+
return nil, nil, fmt.Errorf("signature must be a map")
644+
}
645+
646+
var publicKey []byte
647+
var signature []byte
648+
for _, entry := range *entries {
649+
key, ok := entry.Key.GetSym()
650+
if !ok {
651+
continue
652+
}
653+
switch string(key) {
654+
case "public_key":
655+
bytesVal, ok := entry.Val.GetBytes()
656+
if !ok {
657+
return nil, nil, fmt.Errorf("signature public key must be bytes")
658+
}
659+
publicKey = append([]byte(nil), bytesVal...)
660+
case "signature":
661+
bytesVal, ok := entry.Val.GetBytes()
662+
if !ok {
663+
return nil, nil, fmt.Errorf("signature bytes missing")
664+
}
665+
signature = append([]byte(nil), bytesVal...)
666+
}
667+
}
668+
if len(publicKey) == 0 {
669+
return nil, nil, fmt.Errorf("signature missing public key")
670+
}
671+
if len(signature) == 0 {
672+
return nil, nil, fmt.Errorf("signature missing value")
673+
}
674+
return publicKey, signature, nil
675+
}
676+
343677
// TODO(philip): Below methods are shared with sep10_service.go so they can be moved to a common utility package later.
344678

345679
func (s *sep45Service) getWebAuthDomain(ctx context.Context) string {

0 commit comments

Comments
 (0)