~james/core

core/spindle/debug_keys.go -rw-r--r-- 4.5 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 14 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
package spindle

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/url"
	"slices"
	"sync"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/hashicorp/golang-lru/v2/expirable"
	gossh "golang.org/x/crypto/ssh"

	"tangled.org/core/api/org_tangled"
	"tangled.org/core/rbac"
	"tangled.org/core/spindle/knotclient"
)

const (
	debugKeyTTL = time.Minute
	// the owner plus this many collaborators at most, so one login can't fan out to every pds
	debugKeyHolderCap  = 20
	debugKeysPerHolder = 50
	debugKeyCacheSize  = 512
)

// marshalled keys per did, only successful reads get cached so a pds blip doesn't lock people out for a minute
type debugKeyCache struct {
	once  sync.Once
	byDid *expirable.LRU[syntax.DID, [][]byte]
}

func (c *debugKeyCache) lru() *expirable.LRU[syntax.DID, [][]byte] {
	c.once.Do(func() {
		c.byDid = expirable.NewLRU[syntax.DID, [][]byte](debugKeyCacheSize, nil, debugKeyTTL)
	})
	return c.byDid
}

// fails closed, any error along the way refuses the key
func (s *Spindle) DebugKeyAllowed(ctx context.Context, repoDid syntax.DID, key gossh.PublicKey) bool {
	l := s.l.With("component", "debugssh", "repo", repoDid)
	repo, err := s.db.GetRepoByDid(repoDid)
	if err != nil {
		l.Warn("debug key refused, repo lookup failed", "err", err)
		return false
	}
	if ban, err := s.db.IsBanned(repoDid, repo.Owner); err != nil || ban != nil {
		l.Warn("debug key refused, repo is banned or the ban lookup failed", "err", err)
		return false
	}
	if gate, err := s.db.Gate(&s.cfg.Server, repoDid); err != nil || gate.Paused {
		l.Warn("debug key refused, owner is paused or the gate lookup failed", "err", err)
		return false
	}

	want := key.Marshal()
	for _, holder := range s.debugKeyHolders(l, repoDid, repo.Owner) {
		keys, err := s.sshKeysOf(ctx, holder)
		if err != nil {
			l.Warn("debug key holder skipped, reading their keys failed", "holder", holder, "err", err)
			continue
		}
		if slices.ContainsFunc(keys, func(k []byte) bool { return bytes.Equal(k, want) }) {
			return true
		}
	}
	return false
}

// owner first and a stable order, otherwise the cap could cut different collaborators each login
func (s *Spindle) debugKeyHolders(l *slog.Logger, repoDid, owner syntax.DID) []syntax.DID {
	candidates := []string{owner.String()}
	byRepo, err := s.e.GetCollaboratorsByRepo(rbac.ThisServer)
	if err != nil {
		l.Warn("debug key holders limited to the owner, collaborator lookup failed", "err", err)
	} else {
		candidates = append(candidates, byRepo[repoDid.String()]...)
	}

	var holders []syntax.DID
	for _, candidate := range candidates {
		did, err := syntax.ParseDID(candidate)
		if err != nil || slices.Contains(holders, did) {
			continue
		}
		if ok, err := s.e.IsPushAllowed(candidate, rbac.ThisServer, repoDid.String()); err != nil || !ok {
			continue
		}
		if ban, err := s.db.GetBan(did); err != nil || ban != nil {
			continue
		}
		holders = append(holders, did)
		if len(holders) == debugKeyHolderCap {
			break
		}
	}
	return holders
}

func (s *Spindle) sshKeysOf(ctx context.Context, did syntax.DID) ([][]byte, error) {
	cache := s.debugKeys.lru()
	if keys, ok := cache.Get(did); ok {
		return keys, nil
	}
	pds, err := s.pdsOf(ctx, did)
	if err != nil {
		return nil, err
	}
	records, err := s.knots.ListRecords(ctx, pds, did, org_tangled.KeySshKeyNSID, debugKeysPerHolder)
	if err != nil {
		return nil, fmt.Errorf("listing ssh keys of %s: %w", did, err)
	}
	keys := make([][]byte, 0, len(records))
	for _, raw := range records {
		var record org_tangled.KeySshKey
		if err := json.Unmarshal(raw, &record); err != nil {
			continue
		}
		// usages default to auth and sign when omitted
		if len(record.Usages) > 0 && !slices.Contains(record.Usages, "auth") {
			continue
		}
		parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(record.PublicKey))
		if err != nil {
			continue
		}
		keys = append(keys, parsed.Marshal())
	}
	cache.Add(did, keys)
	return keys, nil
}

func (s *Spindle) pdsOf(ctx context.Context, did syntax.DID) (knotclient.Knot, error) {
	ident, err := s.res.ResolveIdent(ctx, did.String())
	if err != nil {
		return knotclient.Knot{}, fmt.Errorf("resolving %s: %w", did, err)
	}
	pds, ok := knotAt(ident.PDSEndpoint())
	if !ok {
		return knotclient.Knot{}, fmt.Errorf("%s has no usable pds endpoint", did)
	}
	return pds, nil
}

func knotAt(endpoint string) (knotclient.Knot, bool) {
	u, err := url.Parse(endpoint)
	if err != nil || u.Host == "" {
		return knotclient.Knot{}, false
	}
	return knotclient.Knot{Host: u.Host, Scheme: u.Scheme}, true
}