~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
516 lines · 13.6 KB Raw History
package sshtransport

import (
	"cmp"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"

	sshcontract "github.com/johnandersen777/socialweb-computer-kcp/internal/contract/ssh"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/ttlcache"
)

const (
	BadgeBlueKeysNSID = "com.publicdomainrelay.temp.badgeBlueKeys"

	RequesterAssociateService = "requester_associate"

	DefaultCacheTTL = 5 * time.Minute

	DefaultPositiveCacheTTL = 30 * time.Second

	DefaultNegativeCacheTTL = 30 * time.Second

	DefaultMissRecheck = 5 * time.Second

	DefaultMaxCacheEntries = 1024

	DefaultMaxResponseBytes = 1 << 20

	DefaultFetchTimeout = 15 * time.Second

	maxErrorBodyBytes = 4096
)

var ErrRecordNotFound = errors.New("sshtransport: record not found")

type PresentedKey struct {
	Algo string
	Key  string
}

type AuthorizedAccount = sshcontract.Account

type KeyAuthorizer interface {
	Authorize(ctx context.Context, username string, key PresentedKey) (*AuthorizedAccount, error)
}

type NoKeyCouldMatchError struct {
	Username string
	Reason   string
}

func (e *NoKeyCouldMatchError) Error() string {
	return fmt.Sprintf("sshtransport: %s has no key that could be associated with it (%s)", e.Username, e.Reason)
}

type DIDDocument struct {
	DID    string
	Handle string
	PDS    string
}

type IdentityResolver interface {
	ResolveHandle(ctx context.Context, handle string) (string, error)

	ResolveDID(ctx context.Context, did string) (*DIDDocument, error)
}

type DirectoryResolver struct {
	Directory identity.Directory
}

func (r DirectoryResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
	if r.Directory == nil {
		return "", errors.New("sshtransport: no identity directory")
	}
	h, err := syntax.ParseHandle(handle)
	if err != nil {
		return "", err
	}
	id, err := r.Directory.LookupHandle(ctx, h)
	if err != nil {
		return "", err
	}
	return id.DID.String(), nil
}

func (r DirectoryResolver) ResolveDID(ctx context.Context, did string) (*DIDDocument, error) {
	if r.Directory == nil {
		return nil, errors.New("sshtransport: no identity directory")
	}
	d, err := syntax.ParseDID(did)
	if err != nil {
		return nil, err
	}
	id, err := r.Directory.LookupDID(ctx, d)
	if err != nil {
		return nil, err
	}
	doc := &DIDDocument{DID: id.DID.String()}
	if len(id.AlsoKnownAs) > 0 {
		doc.Handle = strings.TrimPrefix(id.AlsoKnownAs[0], "at://")
	}
	doc.Handle = cmp.Or(doc.Handle, doc.DID)
	doc.PDS = id.PDSEndpoint()
	return doc, nil
}

type RecordSource interface {
	GetRecord(ctx context.Context, pds, did, collection, rkey string) (map[string]any, error)
}

type HTTPRecordSource struct {
	TrustedHosts []string

	Trust func(host string) bool

	MaxBytes int64

	Client *http.Client
}

func (s *HTTPRecordSource) GetRecord(ctx context.Context, pds, did, collection, rkey string) (map[string]any, error) {
	base := strings.TrimRight(pds, "/") + "/xrpc/com.atproto.repo.getRecord"
	u, err := url.Parse(base)
	if err != nil {
		return nil, err
	}
	q := u.Query()
	q.Set("repo", did)
	q.Set("collection", collection)
	q.Set("rkey", rkey)
	u.RawQuery = q.Encode()
	trusted := make(map[string]bool, len(s.TrustedHosts))
	host := strings.ToLower(u.Hostname())
	for _, name := range s.TrustedHosts {
		name = strings.ToLower(strings.TrimSpace(name))
		trusted[name] = true
		if name != "" && strings.HasSuffix(host, "."+name) {
			trusted[host] = true
		}
	}
	if s.Trust != nil && host != "" && s.Trust(host) {
		trusted[host] = true
	}
	maxBytes := s.MaxBytes
	maxBytes = opt.Positive(maxBytes, DefaultMaxResponseBytes)
	raw, err := GuardedFetch(ctx, s.Client, u.String(), maxBytes, trusted)
	if err != nil {
		var status *FetchStatusError
		if errors.As(err, &status) && (status.Status == http.StatusNotFound || status.Code == "RecordNotFound" || status.Code == "RepoNotFound") {
			return nil, fmt.Errorf("%w: %v", ErrRecordNotFound, err)
		}
		return nil, err
	}
	var body struct {
		Value map[string]any `json:"value"`
	}
	if err := json.Unmarshal(raw, &body); err != nil {
		return nil, err
	}
	return body.Value, nil
}

type FetchStatusError struct {
	Status int

	Code string

	Origin string
}

func (e *FetchStatusError) Error() string {
	return fmt.Sprintf("%d from %s", e.Status, e.Origin)
}

func IsPrivateAddress(address string) bool {
	ip := net.ParseIP(address)
	if ip == nil {
		return false
	}
	if v4 := ip.To4(); v4 != nil {
		a, b := v4[0], v4[1]
		if a == 0 || a == 10 || a == 127 {
			return true
		}
		if a == 169 && b == 254 {
			return true
		}
		if a == 172 && b >= 16 && b <= 31 {
			return true
		}
		if a == 192 && b == 168 {
			return true
		}
		if a == 100 && b >= 64 && b <= 127 {
			return true
		}
		return a >= 224
	}
	v6 := ip.To16()
	if v6 == nil {
		return false
	}
	if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() {
		return true
	}
	return v6[0]&0xfe == 0xfc
}

func ResolvesToPublicAddress(ctx context.Context, hostname string) bool {
	host := strings.Trim(hostname, "[]")
	if ip := net.ParseIP(host); ip != nil {
		return !IsPrivateAddress(host)
	}
	lower := strings.ToLower(host)
	if lower == "localhost" || strings.HasSuffix(lower, ".localhost") ||
		strings.HasSuffix(lower, ".local") || strings.HasSuffix(lower, ".internal") {
		return false
	}
	cctx, cancel := context.WithTimeout(ctx, DefaultFetchTimeout)
	defer cancel()
	addrs, err := net.DefaultResolver.LookupIPAddr(cctx, host)
	if err != nil || len(addrs) == 0 {
		return false
	}
	for _, addr := range addrs {
		if IsPrivateAddress(addr.IP.String()) {
			return false
		}
	}
	return true
}

func GuardedFetch(ctx context.Context, client *http.Client, rawURL string, maxBytes int64, trustedHosts map[string]bool) ([]byte, error) {
	target, err := url.Parse(rawURL)
	if err != nil {
		return nil, err
	}
	host := target.Hostname()
	loopback := target.Scheme == "http" &&
		(host == "127.0.0.1" || host == "::1" || host == "localhost")
	if target.Scheme != "https" && !loopback && !trustedHosts[host] {
		return nil, fmt.Errorf("sshtransport: refused to fetch %s", target.Scheme+"://"+target.Host)
	}
	if !loopback && !trustedHosts[host] && !ResolvesToPublicAddress(ctx, host) {
		return nil, fmt.Errorf("sshtransport: refused to fetch %s", target.Host)
	}
	cctx, cancel := context.WithTimeout(ctx, DefaultFetchTimeout)
	defer cancel()
	fetch := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	if client != nil {
		copied := *client
		copied.CheckRedirect = fetch.CheckRedirect
		fetch = &copied
	}
	req, err := http.NewRequestWithContext(cctx, http.MethodGet, target.String(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/json")
	resp, err := fetch.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 && resp.StatusCode < 400 {
		return nil, fmt.Errorf("%s tried to redirect the fetch", target.Scheme+"://"+target.Host)
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		failure := &FetchStatusError{Status: resp.StatusCode, Origin: target.Scheme + "://" + target.Host}
		if raw, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)); err == nil {
			var body struct {
				Error string `json:"error"`
			}
			if json.Unmarshal(raw, &body) == nil {
				failure.Code = body.Error
			}
		}
		return nil, failure
	}
	maxBytes = opt.Positive(maxBytes, DefaultMaxResponseBytes)
	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
	if err != nil {
		return nil, err
	}
	if int64(len(raw)) > maxBytes {
		return nil, fmt.Errorf("%s exceeded %d bytes", target.Scheme+"://"+target.Host, maxBytes)
	}
	return raw, nil
}

type KeyAuthorizerOptions struct {
	Resolver IdentityResolver

	Records RecordSource

	CacheTTL time.Duration

	PositiveCacheTTL time.Duration

	NegativeCacheTTL time.Duration

	MissRecheck time.Duration

	MaxCacheEntries int

	Log *slog.Logger
}

type cacheEntry struct {
	account *AuthorizedAccount

	matched bool

	lookupFailed bool

	unknownAccount bool

	ttl time.Duration

	checkedAt time.Time
}

type PDSKeyAuthorizer struct {
	opts KeyAuthorizerOptions

	cache *ttlcache.Cache[string, *cacheEntry]
}

func NewPDSKeyAuthorizer(opts KeyAuthorizerOptions) (*PDSKeyAuthorizer, error) {
	if opts.Resolver == nil {
		return nil, errors.New("sshtransport: key authorizer has no identity resolver")
	}
	if opts.Records == nil {
		return nil, errors.New("sshtransport: key authorizer has no record source")
	}
	opts.CacheTTL = opt.Positive(opts.CacheTTL, DefaultCacheTTL)
	opts.PositiveCacheTTL = opt.Positive(opts.PositiveCacheTTL, DefaultPositiveCacheTTL)
	opts.NegativeCacheTTL = opt.Positive(opts.NegativeCacheTTL, DefaultNegativeCacheTTL)
	opts.MissRecheck = opt.Positive(opts.MissRecheck, DefaultMissRecheck)
	opts.MaxCacheEntries = opt.Positive(opts.MaxCacheEntries, DefaultMaxCacheEntries)
	if opts.Log == nil {
		opts.Log = slog.Default()
	}
	return &PDSKeyAuthorizer{
		opts:  opts,
		cache: ttlcache.New[string, *cacheEntry](ttlcache.Options{MaxEntries: opts.MaxCacheEntries}),
	}, nil
}

func (a *PDSKeyAuthorizer) remember(id string, entry *cacheEntry) {
	a.cache.SetTTL(id, entry, entry.ttl)
}

func (a *PDSKeyAuthorizer) empty(lookupFailed, unknownAccount bool) *cacheEntry {
	now := time.Now()
	return &cacheEntry{
		lookupFailed:   lookupFailed,
		unknownAccount: unknownAccount,
		checkedAt:      now,
		ttl:            a.opts.NegativeCacheTTL,
	}
}

func (a *PDSKeyAuthorizer) resolveAccount(ctx context.Context, username string) (*AuthorizedAccount, bool, error) {
	if !strings.HasPrefix(username, "did:") && !strings.Contains(username, ".") {
		return nil, true, nil
	}
	if after, ok := strings.CutPrefix(username, "did:web:"); ok {
		rest := after
		host, _, _ := strings.Cut(rest, ":")
		decoded, err := url.PathUnescape(host)
		if err != nil || decoded == "" || !ResolvesToPublicAddress(ctx, decoded) {
			return nil, false, errors.New("sshtransport: the did:web host does not resolve publicly")
		}
	}
	did := username
	if !strings.HasPrefix(username, "did:") {
		resolved, err := a.opts.Resolver.ResolveHandle(ctx, username)
		if err != nil {
			return nil, false, err
		}
		did = resolved
	}
	doc, err := a.opts.Resolver.ResolveDID(ctx, did)
	if err != nil {
		return nil, false, err
	}
	if doc == nil || doc.PDS == "" {
		return nil, false, errors.New("sshtransport: the identity names no PDS")
	}
	handle := doc.Handle
	handle = cmp.Or(handle, did)
	return &AuthorizedAccount{DID: did, Handle: handle, PDS: doc.PDS}, false, nil
}

func (a *PDSKeyAuthorizer) load(ctx context.Context, username string, key PresentedKey) *cacheEntry {
	account, unknown, err := a.resolveAccount(ctx, username)
	if err != nil {
		a.opts.Log.Warn("authorize_error", "username", username, "err", err)
		return a.empty(true, false)
	}
	if unknown {
		return a.empty(false, true)
	}
	if account == nil {
		return a.empty(true, false)
	}
	rkey := keyRkey(account.DID, parsedPublicKey{algo: key.Algo, key: key.Key})
	record, err := a.opts.Records.GetRecord(ctx, account.PDS, account.DID, BadgeBlueKeysNSID, rkey)
	if err != nil && !errors.Is(err, ErrRecordNotFound) {
		a.opts.Log.Warn("authorize_error", "username", username, "rkey", rkey, "err", err)
		return a.empty(true, false)
	}
	return &cacheEntry{
		account:   account,
		matched:   err == nil && isRequesterAssociation(record, account.DID) && sshKeyMatches(record["keyId"], key),
		checkedAt: time.Now(),
		ttl:       a.opts.CacheTTL,
	}
}

func (a *PDSKeyAuthorizer) Authorize(ctx context.Context, username string, key PresentedKey) (*AuthorizedAccount, error) {
	now := time.Now()
	id := username + "\x00" + key.Algo + " " + key.Key
	entry, ok := a.cache.Get(id)
	if !ok {
		entry = a.load(ctx, username, key)
		a.remember(id, entry)
	}
	if !entry.matched && now.Sub(entry.checkedAt) >= a.opts.MissRecheck {
		entry = a.load(ctx, username, key)
		a.remember(id, entry)
	}
	if entry.matched && now.Sub(entry.checkedAt) >= a.opts.PositiveCacheTTL {
		entry = a.load(ctx, username, key)
		a.remember(id, entry)
	}
	if entry.lookupFailed {
		return nil, fmt.Errorf("association lookup failed for %s", username)
	}
	if entry.unknownAccount {
		return nil, &NoKeyCouldMatchError{Username: username, Reason: "no_account"}
	}
	if entry.matched {
		return entry.account, nil
	}
	return nil, nil
}

func isRequesterAssociation(record map[string]any, accountDID string) bool {
	if record == nil {
		return false
	}
	challenge, _ := record["challenge"].(string)
	service, _ := record["service"].(string)
	return challenge == accountDID && service == RequesterAssociateService
}

func SplitSSHPublicKey(line string) (PresentedKey, bool) {
	parts := strings.Fields(strings.TrimSpace(line))
	if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
		return PresentedKey{}, false
	}
	return PresentedKey{Algo: parts[0], Key: parts[1]}, true
}

func sshKeyMatches(stored any, presented PresentedKey) bool {
	text, ok := stored.(string)
	if !ok {
		return false
	}
	parsed, ok := SplitSSHPublicKey(text)
	if !ok {
		return false
	}
	return parsed.Algo == presented.Algo && parsed.Key == presented.Key
}

type PDSOverrideResolver struct {
	Inner IdentityResolver

	PDS map[string]string
}

var _ IdentityResolver = PDSOverrideResolver{}

func (r PDSOverrideResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
	return r.Inner.ResolveHandle(ctx, handle)
}

func (r PDSOverrideResolver) ResolveDID(ctx context.Context, did string) (*DIDDocument, error) {
	doc, err := r.Inner.ResolveDID(ctx, did)
	if err != nil {
		return nil, err
	}
	if pds := strings.TrimSpace(r.PDS[doc.DID]); pds != "" {
		doc.PDS = pds
	}
	return doc, nil
}