~bigbes/sr-ht-spec

ref: 6f4efc2b70da87a9dc49ce079a609b9777ee63ed sr-ht-spec/authn/authn_test.go -rw-r--r-- 5.3 KiB
6f4efc2b — bigbes feat(graph): the read-only GraphQL schema at /query 27 days 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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
package authn

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
	"time"

	"github.com/fernet/fernet-go"
	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// testConf is the synthesized instance config every test runs against: a fresh
// random Fernet network-key, a random ed25519 webhook seed (crypto.InitCrypto
// fatally requires one even though v1 emits no webhooks), the owner identity
// the provenance builder reads, and our own section's origin.
var testConf ini.File

// rotatedKey stands in for a Fernet key we no longer hold — a cookie sealed
// with it is indistinguishable from a forgery, which is exactly the point.
var rotatedKey fernet.Key

// TestMain builds that config in memory and runs crypto.InitCrypto once, so
// crypto.Encrypt / DecryptWithoutExpiration share a keyset across the package.
// No network, no Postgres, no files.
func TestMain(m *testing.M) {
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		panic("generate fernet network key: " + err.Error())
	}
	if err := rotatedKey.Generate(); err != nil {
		panic("generate rotated fernet key: " + err.Error())
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		panic("generate webhook seed: " + err.Error())
	}

	testConf = ini.File{
		"sr.ht": ini.Section{
			"network-key": fk.Encode(),
			"owner-name":  "bigbes",
			"owner-email": "bigbes@gmail.com",
		},
		"webhooks": ini.Section{
			"private-key": base64.StdEncoding.EncodeToString(seed),
		},
		ConfigSection: ini.Section{
			"origin": "https://spec.srht.bigb.es",
		},
	}
	crypto.InitCrypto(testConf)

	os.Exit(m.Run())
}

// sealCookie forges a valid unified-login cookie value carrying name, sealed
// with the instance network key — byte for byte what meta.sr.ht would set.
func sealCookie(t *testing.T, name string) string {
	t.Helper()
	payload, err := json.Marshal(auth.AuthCookie{Name: name})
	if err != nil {
		t.Fatalf("marshal cookie claims: %v", err)
	}
	return string(crypto.Encrypt(payload))
}

// sealCookieWithKey forges a cookie under an arbitrary Fernet key, used to
// stand in for a rotated key or an attacker's own.
func sealCookieWithKey(t *testing.T, key *fernet.Key, name string) string {
	t.Helper()
	payload, err := json.Marshal(auth.AuthCookie{Name: name})
	if err != nil {
		t.Fatalf("marshal cookie claims: %v", err)
	}
	tok, err := fernet.EncryptAndSign(payload, key)
	if err != nil {
		t.Fatalf("seal cookie: %v", err)
	}
	return string(tok)
}

// tamper flips one character in the middle of a Fernet token, leaving it
// well-formed base64 so that the HMAC check — not the decoder — is what
// rejects it.
func tamper(t *testing.T, token string) string {
	t.Helper()
	if len(token) < 8 {
		t.Fatalf("token too short to tamper with: %q", token)
	}
	b := []byte(token)
	i := len(b) / 2
	if b[i] == 'A' {
		b[i] = 'B'
	} else {
		b[i] = 'A'
	}
	if string(b) == token {
		t.Fatal("tamper produced an identical token")
	}
	return string(b)
}

// testInstance is the provenance identity set built from testConf.
func testInstance(t *testing.T) Instance {
	t.Helper()
	inst, err := InstanceFromConfig(testConf)
	if err != nil {
		t.Fatalf("InstanceFromConfig: %v", err)
	}
	return inst
}

// stubStore is an in-memory TokenStore keyed by hex(sha256(token)). It can be
// told to fail with an arbitrary error to exercise the transient path, and
// counts calls so tests can assert the cookie path never touches it.
type stubStore struct {
	rows  map[string]AgentToken
	err   error
	calls int
}

func newStubStore() *stubStore { return &stubStore{rows: map[string]AgentToken{}} }

func (s *stubStore) LookupAgentToken(ctx context.Context, hash []byte) (AgentToken, error) {
	s.calls++
	if s.err != nil {
		return AgentToken{}, s.err
	}
	tok, ok := s.rows[hex.EncodeToString(hash)]
	if !ok {
		// The contract db/ must honour: no row means ErrUnknownToken.
		return AgentToken{}, fmt.Errorf("agent_token: %w", ErrUnknownToken)
	}
	return tok, nil
}

// add stores a live token row for the given secret.
func (s *stubStore) add(secret, name string) AgentToken {
	hash := HashToken(secret)
	tok := AgentToken{
		ID:      int64(len(s.rows) + 1),
		Name:    name,
		Hash:    hash,
		Created: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC),
	}
	s.rows[hex.EncodeToString(hash)] = tok
	return tok
}

// revoke stores a revoked token row for the given secret.
func (s *stubStore) revoke(secret, name string) AgentToken {
	tok := s.add(secret, name)
	when := time.Date(2026, 7, 22, 11, 0, 0, 0, time.UTC)
	tok.Revoked = &when
	s.rows[hex.EncodeToString(tok.Hash)] = tok
	return tok
}

// put stores an arbitrary row under an arbitrary lookup key, for the case where
// the store returns a row whose hash does not match what was presented.
func (s *stubStore) put(hash []byte, tok AgentToken) {
	s.rows[hex.EncodeToString(hash)] = tok
}

// request builds a GET / carrying the given cookie value and headers. An empty
// cookie value means no cookie at all.
func request(cookie string, headers map[string]string) *http.Request {
	r := httptest.NewRequest(http.MethodGet, "/", nil)
	if cookie != "" {
		r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
	}
	for k, v := range headers {
		r.Header.Set(k, v)
	}
	return r
}