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