151 lines
3.5 KiB
Go
151 lines
3.5 KiB
Go
package logincreds
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
cryptorand "crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/internal/sdk"
|
|
"github.com/aws/aws-sdk-go-v2/service/signin"
|
|
"github.com/aws/smithy-go/middleware"
|
|
smithyrand "github.com/aws/smithy-go/rand"
|
|
smithyhttp "github.com/aws/smithy-go/transport/http"
|
|
)
|
|
|
|
// AWS signin DPOP always uses the P256 curve
|
|
const curvelen = 256 / 8 // bytes
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc9449
|
|
func mkdpop(token *loginToken, htu string) (string, error) {
|
|
key, err := parseKey(token.DPOPKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse key: %w", err)
|
|
}
|
|
|
|
header, err := jsonb64(&dpopHeader{
|
|
Typ: "dpop+jwt",
|
|
Alg: "ES256",
|
|
Jwk: &dpopHeaderJwk{
|
|
Kty: "EC",
|
|
X: base64.RawURLEncoding.EncodeToString(key.X.Bytes()),
|
|
Y: base64.RawURLEncoding.EncodeToString(key.Y.Bytes()),
|
|
Crv: "P-256",
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal header: %w", err)
|
|
}
|
|
|
|
uuid, err := smithyrand.NewUUID(cryptorand.Reader).GetUUID()
|
|
if err != nil {
|
|
return "", fmt.Errorf("uuid: %w", err)
|
|
}
|
|
|
|
payload, err := jsonb64(&dpopPayload{
|
|
Jti: uuid,
|
|
Htm: "POST",
|
|
Htu: htu,
|
|
Iat: sdk.NowTime().Unix(),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal payload: %w", err)
|
|
}
|
|
|
|
msg := fmt.Sprintf("%s.%s", header, payload)
|
|
|
|
h := sha256.New()
|
|
h.Write([]byte(msg))
|
|
|
|
r, s, err := ecdsa.Sign(cryptorand.Reader, key, h.Sum(nil))
|
|
if err != nil {
|
|
return "", fmt.Errorf("sign: %w", err)
|
|
}
|
|
|
|
// DPOP signatures are formatted in RAW r || s form (with each value padded
|
|
// to fit in curve size which in our case is always the 256 bits) - rather
|
|
// than encoded in something like asn.1
|
|
sig := make([]byte, curvelen*2)
|
|
r.FillBytes(sig[0:curvelen])
|
|
s.FillBytes(sig[curvelen:])
|
|
|
|
dpop := fmt.Sprintf("%s.%s", msg, base64.RawURLEncoding.EncodeToString(sig))
|
|
return dpop, nil
|
|
}
|
|
|
|
func parseKey(pemBlock string) (*ecdsa.PrivateKey, error) {
|
|
block, _ := pem.Decode([]byte(pemBlock))
|
|
priv, err := x509.ParseECPrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse ec private key: %w", err)
|
|
}
|
|
|
|
return priv, nil
|
|
}
|
|
|
|
func jsonb64(v any) (string, error) {
|
|
j, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return base64.RawURLEncoding.EncodeToString(j), nil
|
|
}
|
|
|
|
type dpopHeader struct {
|
|
Typ string `json:"typ"`
|
|
Alg string `json:"alg"`
|
|
Jwk *dpopHeaderJwk `json:"jwk"`
|
|
}
|
|
|
|
type dpopHeaderJwk struct {
|
|
Kty string `json:"kty"`
|
|
X string `json:"x"`
|
|
Y string `json:"y"`
|
|
Crv string `json:"crv"`
|
|
}
|
|
|
|
type dpopPayload struct {
|
|
Jti string `json:"jti"`
|
|
Htm string `json:"htm"`
|
|
Htu string `json:"htu"`
|
|
Iat int64 `json:"iat"`
|
|
}
|
|
|
|
type signDPOP struct {
|
|
Token *loginToken
|
|
}
|
|
|
|
func addSignDPOP(token *loginToken) func(o *signin.Options) {
|
|
return signin.WithAPIOptions(func(stack *middleware.Stack) error {
|
|
return stack.Finalize.Add(&signDPOP{token}, middleware.After)
|
|
})
|
|
}
|
|
|
|
func (*signDPOP) ID() string {
|
|
return "signDPOP"
|
|
}
|
|
|
|
func (m *signDPOP) HandleFinalize(
|
|
ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
|
|
out middleware.FinalizeOutput, md middleware.Metadata, err error,
|
|
) {
|
|
req, ok := in.Request.(*smithyhttp.Request)
|
|
if !ok {
|
|
return out, md, fmt.Errorf("unexpected transport type %T", req)
|
|
}
|
|
|
|
dpop, err := mkdpop(m.Token, req.URL.String())
|
|
if err != nil {
|
|
return out, md, fmt.Errorf("sign dpop: %w", err)
|
|
}
|
|
|
|
req.Header.Set("DPoP", dpop)
|
|
return next.HandleFinalize(ctx, in)
|
|
}
|