~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
148 lines · 4.0 KB Raw History
package pdsclient

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"
	"time"

	"github.com/bluesky-social/indigo/atproto/atcrypto"
)

const dpopProofLifetime = 30 * time.Second

func DPoPSourceFromData(data map[string]any) (DPoPSource, error) {
	key, err := dpopKey(data)
	if err != nil {
		return nil, err
	}
	return &dpopSigner{key: key}, nil
}

func dpopKey(data map[string]any) (atcrypto.PrivateKeyExportable, error) {
	if value, ok := data["dpopPrivateJwk"]; ok {
		key, err := dpopKeyValue(value)
		if err == nil {
			return key, nil
		}
	}
	for _, name := range []string{"dpopPrivateKeyMultibase", "dpop_privatekey_multibase", "privateKeyMultibase", "private_key_multibase", "dpopPrivateKey", "privateKey", "key", "value"} {
		value, ok := data[name]
		if !ok {
			continue
		}
		key, err := dpopKeyValue(value)
		if err == nil {
			return key, nil
		}
	}
	return nil, fmt.Errorf("%w: the dpop key ref holds no private key", ErrInvalidConfig)
}

func dpopKeyValue(value any) (atcrypto.PrivateKeyExportable, error) {
	switch typed := value.(type) {
	case map[string]any:
		d, _ := typed["d"].(string)
		if d == "" {
			return nil, fmt.Errorf("%w: the dpop jwk has no d", ErrInvalidConfig)
		}
		return dpopKeyP256(d)
	case string:
		raw := strings.TrimSpace(typed)
		if raw == "" {
			return nil, fmt.Errorf("%w: the dpop key is empty", ErrInvalidConfig)
		}
		if strings.HasPrefix(raw, "{") {
			var jwk map[string]any
			if err := json.Unmarshal([]byte(raw), &jwk); err != nil {
				return nil, fmt.Errorf("%w: the dpop jwk: %v", ErrInvalidConfig, err)
			}
			return dpopKeyValue(jwk)
		}
		if strings.HasPrefix(raw, "z") {
			key, err := atcrypto.ParsePrivateMultibase(raw)
			if err != nil {
				return nil, fmt.Errorf("%w: the dpop multibase key: %v", ErrInvalidConfig, err)
			}
			return key, nil
		}
		return dpopKeyP256(raw)
	}
	return nil, fmt.Errorf("%w: unsupported dpop key value", ErrInvalidConfig)
}

func dpopKeyP256(raw string) (atcrypto.PrivateKeyExportable, error) {
	decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(strings.TrimSpace(raw), "="))
	if err != nil {
		return nil, fmt.Errorf("%w: the dpop key: %v", ErrInvalidConfig, err)
	}
	key, err := atcrypto.ParsePrivateBytesP256(decoded)
	if err != nil {
		return nil, fmt.Errorf("%w: the dpop key: %v", ErrInvalidConfig, err)
	}
	return key, nil
}

type dpopSigner struct {
	key atcrypto.PrivateKeyExportable
}

func (s *dpopSigner) DPoPProof(ctx context.Context, method, target, accessToken string) (string, error) {
	if s == nil || s.key == nil {
		return "", fmt.Errorf("%w: no dpop key", ErrInvalidConfig)
	}
	parsed, err := url.Parse(target)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
		return "", fmt.Errorf("%w: the dpop target %q is not absolute", ErrInvalidRequest, target)
	}
	public, err := s.key.PublicKey()
	if err != nil {
		return "", err
	}
	jwk, err := public.JWK()
	if err != nil {
		return "", err
	}
	header, err := json.Marshal(struct {
		Typ string        `json:"typ"`
		Alg string        `json:"alg"`
		JWK *atcrypto.JWK `json:"jwk"`
	}{Typ: "dpop+jwt", Alg: "ES256", JWK: jwk})
	if err != nil {
		return "", err
	}
	nonce := make([]byte, 16)
	if _, err := rand.Read(nonce); err != nil {
		return "", err
	}
	htu := *parsed
	htu.RawQuery = ""
	htu.Fragment = ""
	now := time.Now()
	claims := map[string]any{
		"jti": base64.RawURLEncoding.EncodeToString(nonce),
		"htm": strings.ToUpper(method),
		"htu": htu.String(),
		"iat": now.Unix(),
		"exp": now.Add(dpopProofLifetime).Unix(),
	}
	if accessToken != "" {
		sum := sha256.Sum256([]byte(accessToken))
		claims["ath"] = base64.RawURLEncoding.EncodeToString(sum[:])
	}
	payload, err := json.Marshal(claims)
	if err != nil {
		return "", err
	}
	signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
	signature, err := s.key.HashAndSign([]byte(signingInput))
	if err != nil {
		return "", err
	}
	return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}