dpop.go raw

   1  package logincreds
   2  
   3  import (
   4  	"context"
   5  	"crypto/ecdsa"
   6  	cryptorand "crypto/rand"
   7  	"crypto/sha256"
   8  	"crypto/x509"
   9  	"encoding/base64"
  10  	"encoding/json"
  11  	"encoding/pem"
  12  	"fmt"
  13  
  14  	"github.com/aws/aws-sdk-go-v2/internal/sdk"
  15  	"github.com/aws/aws-sdk-go-v2/service/signin"
  16  	"github.com/aws/smithy-go/middleware"
  17  	smithyrand "github.com/aws/smithy-go/rand"
  18  	smithyhttp "github.com/aws/smithy-go/transport/http"
  19  )
  20  
  21  // AWS signin DPOP always uses the P256 curve
  22  const curvelen = 256 / 8 // bytes
  23  
  24  // https://datatracker.ietf.org/doc/html/rfc9449
  25  func mkdpop(token *loginToken, htu string) (string, error) {
  26  	key, err := parseKey(token.DPOPKey)
  27  	if err != nil {
  28  		return "", fmt.Errorf("parse key: %w", err)
  29  	}
  30  
  31  	header, err := jsonb64(&dpopHeader{
  32  		Typ: "dpop+jwt",
  33  		Alg: "ES256",
  34  		Jwk: &dpopHeaderJwk{
  35  			Kty: "EC",
  36  			X:   base64.RawURLEncoding.EncodeToString(key.X.Bytes()),
  37  			Y:   base64.RawURLEncoding.EncodeToString(key.Y.Bytes()),
  38  			Crv: "P-256",
  39  		},
  40  	})
  41  	if err != nil {
  42  		return "", fmt.Errorf("marshal header: %w", err)
  43  	}
  44  
  45  	uuid, err := smithyrand.NewUUID(cryptorand.Reader).GetUUID()
  46  	if err != nil {
  47  		return "", fmt.Errorf("uuid: %w", err)
  48  	}
  49  
  50  	payload, err := jsonb64(&dpopPayload{
  51  		Jti: uuid,
  52  		Htm: "POST",
  53  		Htu: htu,
  54  		Iat: sdk.NowTime().Unix(),
  55  	})
  56  	if err != nil {
  57  		return "", fmt.Errorf("marshal payload: %w", err)
  58  	}
  59  
  60  	msg := fmt.Sprintf("%s.%s", header, payload)
  61  
  62  	h := sha256.New()
  63  	h.Write([]byte(msg))
  64  
  65  	r, s, err := ecdsa.Sign(cryptorand.Reader, key, h.Sum(nil))
  66  	if err != nil {
  67  		return "", fmt.Errorf("sign: %w", err)
  68  	}
  69  
  70  	// DPOP signatures are formatted in RAW r || s form (with each value padded
  71  	// to fit in curve size which in our case is always the 256 bits) - rather
  72  	// than encoded in something like asn.1
  73  	sig := make([]byte, curvelen*2)
  74  	r.FillBytes(sig[0:curvelen])
  75  	s.FillBytes(sig[curvelen:])
  76  
  77  	dpop := fmt.Sprintf("%s.%s", msg, base64.RawURLEncoding.EncodeToString(sig))
  78  	return dpop, nil
  79  }
  80  
  81  func parseKey(pemBlock string) (*ecdsa.PrivateKey, error) {
  82  	block, _ := pem.Decode([]byte(pemBlock))
  83  	priv, err := x509.ParseECPrivateKey(block.Bytes)
  84  	if err != nil {
  85  		return nil, fmt.Errorf("parse ec private key: %w", err)
  86  	}
  87  
  88  	return priv, nil
  89  }
  90  
  91  func jsonb64(v any) (string, error) {
  92  	j, err := json.MarshalIndent(v, "", "  ")
  93  	if err != nil {
  94  		return "", err
  95  	}
  96  
  97  	return base64.RawURLEncoding.EncodeToString(j), nil
  98  }
  99  
 100  type dpopHeader struct {
 101  	Typ string         `json:"typ"`
 102  	Alg string         `json:"alg"`
 103  	Jwk *dpopHeaderJwk `json:"jwk"`
 104  }
 105  
 106  type dpopHeaderJwk struct {
 107  	Kty string `json:"kty"`
 108  	X   string `json:"x"`
 109  	Y   string `json:"y"`
 110  	Crv string `json:"crv"`
 111  }
 112  
 113  type dpopPayload struct {
 114  	Jti string `json:"jti"`
 115  	Htm string `json:"htm"`
 116  	Htu string `json:"htu"`
 117  	Iat int64  `json:"iat"`
 118  }
 119  
 120  type signDPOP struct {
 121  	Token *loginToken
 122  }
 123  
 124  func addSignDPOP(token *loginToken) func(o *signin.Options) {
 125  	return signin.WithAPIOptions(func(stack *middleware.Stack) error {
 126  		return stack.Finalize.Add(&signDPOP{token}, middleware.After)
 127  	})
 128  }
 129  
 130  func (*signDPOP) ID() string {
 131  	return "signDPOP"
 132  }
 133  
 134  func (m *signDPOP) HandleFinalize(
 135  	ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
 136  	out middleware.FinalizeOutput, md middleware.Metadata, err error,
 137  ) {
 138  	req, ok := in.Request.(*smithyhttp.Request)
 139  	if !ok {
 140  		return out, md, fmt.Errorf("unexpected transport type %T", req)
 141  	}
 142  
 143  	dpop, err := mkdpop(m.Token, req.URL.String())
 144  	if err != nil {
 145  		return out, md, fmt.Errorf("sign dpop: %w", err)
 146  	}
 147  
 148  	req.Header.Set("DPoP", dpop)
 149  	return next.HandleFinalize(ctx, in)
 150  }
 151