tinyrelaycommit

Commit f3b260bcec34

browse files at this commit | history

1diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
2index 1e6185b..0bfe60a 100644
3--- a/internal/auth/auth_test.go
4+++ b/internal/auth/auth_test.go
5@@ -57,6 +57,59 @@ func TestNIP98BindsSchemeQueryAndPath(t *testing.T) {
6 }
7 }
8
9+func TestGRASP08ReusesRepositoryRootProofAcrossSmartHTTP(t *testing.T) {
10+ now := time.Unix(1_700_000_000, 0)
11+ validator := NewValidator(func() time.Time { return now })
12+ root := "https://relay.example/npub1owner/repo.git"
13+ e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}, {"payload", sha256Hex("different body")}})
14+ header := token(t, e)
15+ for _, request := range []string{root + "/info/refs?service=git-upload-pack", root + "/git-upload-pack"} {
16+ if _, err := validator.VerifyGRASP08(header, request); err != nil {
17+ t.Fatalf("verify reusable proof for %s: %v", request, err)
18+ }
19+ }
20+}
21+
22+func TestGRASP08RepositoryRootHandlesGitSubstringInSlug(t *testing.T) {
23+ got, ok := GRASP08RepositoryRoot("https://relay.example/npub1owner/foo.git-tools.git/info/refs?service=git-upload-pack")
24+ if !ok || got != "https://relay.example/npub1owner/foo.git-tools.git" {
25+ t.Fatalf("root=%q ok=%v", got, ok)
26+ }
27+}
28+
29+func TestGRASP08RejectsWrongRootMethodAndAgeWithUniformError(t *testing.T) {
30+ now := time.Unix(1_700_000_000, 0)
31+ validator := NewValidator(func() time.Time { return now })
32+ for _, tc := range []struct {
33+ name string
34+ tags [][]string
35+ at int64
36+ url string
37+ }{
38+ {"wrong root", [][]string{{"u", "https://relay.example/npub1owner/other.git"}, {"method", "GET"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
39+ {"wrong method", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "POST"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
40+ {"expired", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}}, now.Add(-61 * time.Second).Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},
41+ } {
42+ t.Run(tc.name, func(t *testing.T) {
43+ _, err := validator.VerifyGRASP08(token(t, signedEvent(t, 27235, tc.at, "", tc.tags)), tc.url)
44+ if err != ErrGRASP08Unauthorized {
45+ t.Fatalf("error=%v, want uniform error", err)
46+ }
47+ })
48+ }
49+}
50+
51+func TestGRASP08RejectsUnsupportedGitEndpoint(t *testing.T) {
52+ now := time.Unix(1_700_000_000, 0)
53+ validator := NewValidator(func() time.Time { return now })
54+ e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}})
55+ for _, path := range []string{"/objects/pack/a", "/info/refs/extra", "/"} {
56+ if _, err := validator.VerifyGRASP08(token(t, e), "https://relay.example/npub1owner/repo.git"+path); err != ErrGRASP08Unauthorized {
57+ t.Fatalf("endpoint %s accepted with error %v", path, err)
58+ }
59+ }
60+}
61+
62 func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) {
63 now := time.Unix(1_700_000_000, 0)
64 validator := NewValidator(func() time.Time { return now })
65@@ -179,3 +232,17 @@ func sha256Hex(value string) string {
66 sum := sha256.Sum256([]byte(value))
67 return hex.EncodeToString(sum[:])
68 }
69+
70+func TestGRASP08RepositoryRootKeepsClientEscaping(t *testing.T) {
71+ now := time.Unix(1_700_000_000, 0)
72+ validator := NewValidator(func() time.Time { return now })
73+ root := "https://relay.example/npub1owner/a%3Ab.git"
74+ got, ok := GRASP08RepositoryRoot(root + "/info/refs?service=git-upload-pack")
75+ if !ok || got != root {
76+ t.Fatalf("root=%q ok=%v", got, ok)
77+ }
78+ e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}})
79+ if _, err := validator.VerifyGRASP08(token(t, e), root+"/git-upload-pack"); err != nil {
80+ t.Fatalf("verify proof for an escaped identifier: %v", err)
81+ }
82+}
83diff --git a/internal/auth/grasp08.go b/internal/auth/grasp08.go
84new file mode 100644
85index 0000000..3d98b26
86--- /dev/null
87+++ b/internal/auth/grasp08.go
88@@ -0,0 +1,71 @@
89+package auth
90+
91+import (
92+ "errors"
93+ "net/url"
94+ "strings"
95+
96+ "github.com/FelineStateMachine/tinyrelay/internal/event"
97+)
98+
99+// ErrGRASP08Unauthorized is intentionally detail-free. GRASP-08 uses an
100+// empty 401 challenge so a caller cannot distinguish an unknown repository
101+// from an unauthorized one.
102+var ErrGRASP08Unauthorized = errors.New("auth-required:")
103+
104+// VerifyGRASP08 verifies the reusable repository-root proof used by the
105+// GRASP-08 private-service profile. The proof is a kind 27235 event naming
106+// the repository root and GET; it is valid for 60 seconds and is reusable
107+// across the repository's Smart HTTP requests. Unlike NIP-98, payload tags
108+// are intentionally ignored and the proof is not replay-tracked.
109+func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) {
110+ e, err := decodeToken(header, "GRASP-08")
111+ if err != nil {
112+ return event.Event{}, ErrGRASP08Unauthorized
113+ }
114+ if err := event.Validate(e); err != nil || e.Kind != 27235 {
115+ return event.Event{}, ErrGRASP08Unauthorized
116+ }
117+ now := v.now()
118+ if e.CreatedAt < now.Unix()-60 || e.CreatedAt > now.Unix()+60 {
119+ return event.Event{}, ErrGRASP08Unauthorized
120+ }
121+ if event.Tag(e, "method") != "GET" {
122+ return event.Event{}, ErrGRASP08Unauthorized
123+ }
124+ root, ok := GRASP08RepositoryRoot(rawURL)
125+ if !ok || sameRequestURL(event.Tag(e, "u"), root) != nil {
126+ return event.Event{}, ErrGRASP08Unauthorized
127+ }
128+ return e, nil
129+}
130+
131+// GRASP08RepositoryRoot returns the canonical repository URL for a Smart HTTP
132+// request beneath a .git path.
133+func GRASP08RepositoryRoot(raw string) (string, bool) {
134+ u, err := url.Parse(raw)
135+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
136+ return "", false
137+ }
138+ // Keep the client's percent-encoding so the root compares equal to the
139+ // signed u tag, which is also kept as sent.
140+ path := u.EscapedPath()
141+ i := strings.LastIndex(path, ".git")
142+ if i < 0 || (i+4 < len(path) && path[i+4] != '/') {
143+ return "", false
144+ }
145+ rootPath := path[:i+4]
146+ suffix := path[i+4:]
147+ if suffix != "" && suffix != "/info/refs" && suffix != "/git-upload-pack" && suffix != "/git-receive-pack" {
148+ return "", false
149+ }
150+ if strings.HasSuffix(rootPath, "/") {
151+ return "", false
152+ }
153+ unescaped, err := url.PathUnescape(rootPath)
154+ if err != nil {
155+ return "", false
156+ }
157+ u.Path, u.RawPath, u.RawQuery, u.Fragment = unescaped, rootPath, "", ""
158+ return u.String(), true
159+}
160diff --git a/internal/configport/configport.go b/internal/configport/configport.go
161index 68d89a4..e0a578d 100644
162--- a/internal/configport/configport.go
163+++ b/internal/configport/configport.go
164@@ -65,6 +65,8 @@ type ConfigStore struct {
165 // integration point for replacing an in-memory policy and closing stale
166 // subscriptions.
167 OnApplied func(policy.Policy)
168+ // ValidatePolicy runs before a config transaction is committed.
169+ ValidatePolicy func(policy.Policy) error
170 }
171
172 type ApplyOptions struct {
173@@ -340,6 +342,11 @@ func (s *ConfigStore) ApplyWithOptions(ctx context.Context, c Config, opts Apply
174 if err != nil {
175 return Changes{}, err
176 }
177+ if s.ValidatePolicy != nil {
178+ if err := s.ValidatePolicy(ch.FinalPolicy); err != nil {
179+ return Changes{}, err
180+ }
181+ }
182 if opts.DryRun {
183 return ch, nil
184 }
185diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go
186index 9097fe0..9af6d8b 100644
187--- a/internal/daemon/capabilities.go
188+++ b/internal/daemon/capabilities.go
189@@ -111,9 +111,18 @@ func CapabilityRegistryWithServices(p policy.Policy, gitAvailable, blobsAvailabl
190 if gitAvailable && p.Features.Grasp {
191 setCapability(capabilities, "NIP-34", Capability{ID: "NIP-34", Status: "enabled", Reason: "Git repository support"})
192 profiles := []string{"GRASP-01"}
193+ if p.Features.Grasp02 {
194+ profiles = append(profiles, "GRASP-02")
195+ if p.Features.Grasp03 {
196+ profiles = append(profiles, "GRASP-03")
197+ }
198+ }
199 if p.Features.Grasp06 {
200 profiles = append(profiles, "GRASP-06")
201 }
202+ if privatePolicy(p) {
203+ profiles = append(profiles, "GRASP-08")
204+ }
205 setCapability(capabilities, "GRASP", Capability{ID: "GRASP", Status: "enabled", Reason: "configured GRASP profiles", Profiles: profiles})
206 } else if gitAvailable {
207 setCapability(capabilities, "NIP-34", Capability{ID: "NIP-34", Status: "configured", Reason: "Git support is available; GRASP policy is disabled"})
208diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go
209index a181d61..cf15a63 100644
210--- a/internal/daemon/daemon_test.go
211+++ b/internal/daemon/daemon_test.go
212@@ -72,7 +72,11 @@ func TestTenantIsolationAndDurableHTTPPublish(t *testing.T) {
213 func signRequest(t *testing.T, r *http.Request, body string) {
214 t.Helper()
215 hash := sha256.Sum256([]byte(body))
216- e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}}
217+ tags := [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}
218+ if i := strings.Index(r.URL.Path, ".git/"); i >= 0 {
219+ tags = [][]string{{"u", r.URL.Scheme + "://" + r.URL.Host + r.URL.Path[:i+4]}, {"method", http.MethodGet}}
220+ }
221+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: tags}
222 if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {
223 t.Fatal(err)
224 }
225diff --git a/internal/daemon/git_private_restart_test.go b/internal/daemon/git_private_restart_test.go
226index 3388900..55b7a17 100644
227--- a/internal/daemon/git_private_restart_test.go
228+++ b/internal/daemon/git_private_restart_test.go
229@@ -33,6 +33,8 @@ func TestPrivateSignedGitStateRemainsHiddenAfterRestart(t *testing.T) {
230 }
231 p := tenant.Policy()
232 p.Features.Grasp = true
233+ p.Features.Grasp08 = true
234+ p.Reads = "members"
235 if err := tenant.applyPolicy(ctx, p); err != nil {
236 t.Fatal(err)
237 }
238@@ -73,8 +75,8 @@ func TestPrivateSignedGitStateRemainsHiddenAfterRestart(t *testing.T) {
239 req := httptest.NewRequest(http.MethodGet, "http://relay.test/r/main"+path, nil)
240 denied := httptest.NewRecorder()
241 app.ServeHTTP(denied, req)
242- if denied.Code != http.StatusForbidden {
243- t.Fatalf("unsigned private Git read status=%d", denied.Code)
244+ if denied.Code != http.StatusUnauthorized || denied.Body.Len() != 0 {
245+ t.Fatalf("unsigned private Git read status=%d body=%q", denied.Code, denied.Body.String())
246 }
247 req = httptest.NewRequest(http.MethodGet, "http://relay.test/r/main"+path, nil)
248 signRequest(t, req, "")
249@@ -98,7 +100,7 @@ func TestPrivateSignedGitStateRemainsHiddenAfterRestart(t *testing.T) {
250 signRequest(t, tampered, "0001")
251 rejected := httptest.NewRecorder()
252 app.ServeHTTP(rejected, tampered)
253- if rejected.Code != http.StatusForbidden || !strings.Contains(rejected.Body.String(), "payload hash") {
254- t.Fatalf("tampered Git payload reached protocol handling: %d %q", rejected.Code, rejected.Body.String())
255+ if rejected.Code != http.StatusOK {
256+ t.Fatalf("GRASP-08 reusable proof rejected body variation: %d %q", rejected.Code, rejected.Body.String())
257 }
258 }
259diff --git a/internal/daemon/grasp_capabilities_test.go b/internal/daemon/grasp_capabilities_test.go
260new file mode 100644
261index 0000000..e7538be
262--- /dev/null
263+++ b/internal/daemon/grasp_capabilities_test.go
264@@ -0,0 +1,43 @@
265+package daemon
266+
267+import (
268+ "context"
269+ "encoding/json"
270+ "net/http"
271+ "net/http/httptest"
272+ "reflect"
273+ "testing"
274+)
275+
276+func TestPrivateNIP11MatchesRuntimeGRASPCapabilities(t *testing.T) {
277+ _, tenant := testTenant(t)
278+ p := tenant.Policy()
279+ p.Features.Grasp = true
280+ p.Features.Grasp02 = true
281+ p.Features.Grasp03 = true
282+ p.Features.Grasp06 = true
283+ p.Features.Grasp08 = true
284+ p.Reads = "members"
285+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
286+ t.Fatal(err)
287+ }
288+ req := httptest.NewRequest(http.MethodGet, "http://relay.test/", nil)
289+ req.Header.Set("Accept", "application/nostr+json")
290+ res := httptest.NewRecorder()
291+ tenant.ServeHTTP(res, req)
292+ var info struct {
293+ Profiles []string `json:"supported_grasps"`
294+ }
295+ if err := json.Unmarshal(res.Body.Bytes(), &info); err != nil {
296+ t.Fatal(err)
297+ }
298+ want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}
299+ if !reflect.DeepEqual(info.Profiles, want) {
300+ t.Fatalf("NIP-11 profiles = %v", info.Profiles)
301+ }
302+ for _, capability := range tenant.Capabilities(nil) {
303+ if capability.ID == "GRASP" && !reflect.DeepEqual(capability.Profiles, info.Profiles) {
304+ t.Fatalf("runtime profiles = %v; NIP-11 = %v", capability.Profiles, info.Profiles)
305+ }
306+ }
307+}
308diff --git a/internal/daemon/http.go b/internal/daemon/http.go
309index 4c21630..aaf918e 100644
310--- a/internal/daemon/http.go
311+++ b/internal/daemon/http.go
312@@ -82,21 +82,17 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
313 return
314 }
315 if privatePolicy(p) && strings.TrimSpace(r.Header.Get("Authorization")) == "" {
316- http.Error(w, "auth-required: private Git hosting requires NIP-98", http.StatusUnauthorized)
317+ writeGRASP08Challenge(w)
318 return
319 }
320 if privatePolicy(p) {
321- proof, authErr := t.auth.VerifyNIP98(r.Header.Get("Authorization"), t.requestURL(r), r.Method, "")
322+ proof, authErr := t.auth.VerifyGRASP08(r.Header.Get("Authorization"), t.requestURL(r))
323 if authErr != nil {
324- http.Error(w, authErr.Error(), http.StatusUnauthorized)
325+ writeGRASP08Challenge(w)
326 return
327 }
328 if accessErr := t.requirePrivateAccess(r.Context(), proof.PubKey); accessErr != nil {
329- http.Error(w, accessErr.Error(), http.StatusForbidden)
330- return
331- }
332- if spoolErr := spoolGitPayload(r, proof); spoolErr != nil {
333- http.Error(w, spoolErr.Error(), http.StatusUnauthorized)
334+ writeGRASP08Challenge(w)
335 return
336 }
337 r = privateGitRequest(r, proof)
338@@ -143,6 +139,11 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
339 http.NotFound(w, r)
340 }
341
342+func writeGRASP08Challenge(w http.ResponseWriter) {
343+ w.Header().Set("WWW-Authenticate", `Nostr method="GET"`)
344+ w.WriteHeader(http.StatusUnauthorized)
345+}
346+
347 func (t *Tenant) webAddress(w http.ResponseWriter, r *http.Request) {
348 actor, err := t.resolveUIActor(r)
349 if err != nil {
350@@ -331,12 +332,9 @@ func (t *Tenant) information(w http.ResponseWriter, r *http.Request) {
351 if t.records != nil {
352 info["self"] = t.records.PublicKey()
353 }
354- if p.Features.Grasp && t.git != nil && !privatePolicy(p) {
355+ if p.Features.Grasp && t.git != nil {
356 info["supported_grasps"] = t.git.SupportedGRASPs()
357 }
358- if privatePolicy(p) && t.git != nil {
359- info["supported_grasps"] = []string{"GRASP-01", "GRASP-08"}
360- }
361 if p.Features.Sites.Enabled && !privatePolicy(p) {
362 base, _ := url.Parse(t.publicURL)
363 domain := t.siteDomain()
364diff --git a/internal/daemon/private_grasp.go b/internal/daemon/private_grasp.go
365index 4e7a8a7..74084ef 100644
366--- a/internal/daemon/private_grasp.go
367+++ b/internal/daemon/private_grasp.go
368@@ -10,11 +10,11 @@ import (
369 )
370
371 type privateGitProofKey struct{}
372-type privateGitSpoolKey struct{}
373+type privateGitPayloadCheckedKey struct{}
374
375 func privateGitRequest(r *http.Request, proof event.Event) *http.Request {
376 ctx := context.WithValue(r.Context(), privateGitProofKey{}, proof)
377- ctx = context.WithValue(ctx, privateGitSpoolKey{}, true)
378+ ctx = context.WithValue(ctx, privateGitPayloadCheckedKey{}, struct{}{})
379 return r.WithContext(ctx)
380 }
381
382diff --git a/internal/daemon/private_grasp_test.go b/internal/daemon/private_grasp_test.go
383index b62fdca..b9eb55e 100644
384--- a/internal/daemon/private_grasp_test.go
385+++ b/internal/daemon/private_grasp_test.go
386@@ -137,7 +137,7 @@ func TestPrivateGitRejectsSignedOutsiderBeforeRepositoryLookup(t *testing.T) {
387 signRequestWithSecret(t, outsider, "", strings.Repeat("3", 64))
388 denied := httptest.NewRecorder()
389 tenant.ServeHTTP(denied, outsider)
390- if denied.Code != http.StatusForbidden {
391+ if denied.Code != http.StatusUnauthorized || denied.Body.Len() != 0 || denied.Header().Get("WWW-Authenticate") != `Nostr method="GET"` {
392 t.Fatalf("signed outsider reached private Git lookup: %d %s", denied.Code, denied.Body.String())
393 }
394 owner := httptest.NewRequest(http.MethodGet, "http://relay.test"+path, nil)
395@@ -152,7 +152,11 @@ func TestPrivateGitRejectsSignedOutsiderBeforeRepositoryLookup(t *testing.T) {
396 func signRequestWithSecret(t *testing.T, r *http.Request, body, secret string) {
397 t.Helper()
398 hash := sha256.Sum256([]byte(body))
399- e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}}
400+ tags := [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}
401+ if i := strings.Index(r.URL.Path, ".git/"); i >= 0 {
402+ tags = [][]string{{"u", r.URL.Scheme + "://" + r.URL.Host + r.URL.Path[:i+4]}, {"method", http.MethodGet}}
403+ }
404+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: tags}
405 if err := event.Sign(&e, secret); err != nil {
406 t.Fatal(err)
407 }
408diff --git a/internal/daemon/private_peer.go b/internal/daemon/private_peer.go
409index e8bb68c..a7dc054 100644
410--- a/internal/daemon/private_peer.go
411+++ b/internal/daemon/private_peer.go
412@@ -10,16 +10,24 @@ import (
413 "net/http"
414 "net/url"
415 "strings"
416+
417+ "github.com/FelineStateMachine/tinyrelay/internal/auth"
418 )
419
420 func (t *Tenant) privateHTTPAuth(ctx context.Context, method, rawURL, payloadHash string) (string, error) {
421+ _ = method
422+ _ = payloadHash
423 if t == nil || t.records == nil || !t.PrivateServiceEnabled() {
424 return "", fmt.Errorf("private peer: relay identity unavailable")
425 }
426 if !privatePeerMatch(rawURL, t.Policy().PrivatePeers) {
427 return "", fmt.Errorf("private peer: target is no longer configured")
428 }
429- proof, err := t.records.SignNIP98(ctx, method, rawURL, payloadHash, 0)
430+ root, ok := auth.GRASP08RepositoryRoot(rawURL)
431+ if !ok {
432+ return "", fmt.Errorf("private peer: target is not a Git repository URL")
433+ }
434+ proof, err := t.records.SignNIP98(ctx, http.MethodGet, root, "", 0)
435 if err != nil {
436 return "", err
437 }
438diff --git a/internal/daemon/private_peer_acceptance_test.go b/internal/daemon/private_peer_acceptance_test.go
439index da6bf6d..6d6f029 100644
440--- a/internal/daemon/private_peer_acceptance_test.go
441+++ b/internal/daemon/private_peer_acceptance_test.go
442@@ -124,13 +124,20 @@ func TestPrivatePeerGitAndMetadataSyncAcrossTenants(t *testing.T) {
443 publicPolicy.Features.Grasp08 = false
444 publicPolicy.Reads = "open"
445 publicPolicy.PrivatePeers = []string{guardPeer.URL}
446- if err := source.applyPolicy(ctx, publicPolicy); err != nil {
447+ if err := source.applyPolicy(ctx, publicPolicy); err == nil {
448+ t.Fatal("private tenant policy was made public while private repository metadata existed")
449+ }
450+ _, publicTenant := testTenant(t)
451+ publicPolicy = publicTenant.Policy()
452+ publicPolicy.Features.Grasp = true
453+ publicPolicy.PrivatePeers = []string{guardPeer.URL}
454+ if err := publicTenant.applyPolicy(ctx, publicPolicy); err != nil {
455 t.Fatal(err)
456 }
457 publicCheckRepo := repo
458 publicCheckRepo.Identifier = "public-profile-peer"
459 publicCheckRepo.Clone = []string{guardPeer.URL + "/private.git"}
460- if err := source.gitSync(ctx, publicCheckRepo); err == nil {
461+ if err := publicTenant.gitSync(ctx, publicCheckRepo); err == nil {
462 t.Fatal("public profile unexpectedly fetched from a private peer")
463 }
464 if publicAuth != "" {
465diff --git a/internal/daemon/private_repository.go b/internal/daemon/private_repository.go
466new file mode 100644
467index 0000000..5249925
468--- /dev/null
469+++ b/internal/daemon/private_repository.go
470@@ -0,0 +1,49 @@
471+package daemon
472+
473+import (
474+ "context"
475+ "errors"
476+ "fmt"
477+
478+ "github.com/FelineStateMachine/tinyrelay/internal/event"
479+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
480+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
481+)
482+
483+// validatePrivateRepositoryPlacement prevents a private GRASP repository from
484+// being admitted to an open tenant. State events inherit the marker from their
485+// stored announcement, including when the announcement predates this guard.
486+func (t *Tenant) validatePrivateRepositoryPlacement(ctx context.Context, e event.Event) error {
487+ if e.Kind != event.KIND_REPO && e.Kind != event.KIND_REPO_STATE {
488+ return nil
489+ }
490+ private, err := privateRepositoryStored(ctx, t.store, e)
491+ if err != nil {
492+ return fmt.Errorf("check private repository placement: %w", err)
493+ }
494+ if !private {
495+ return nil
496+ }
497+ p := t.Policy()
498+ if !p.Features.Grasp08 || p.Reads != "members" {
499+ return errors.New("blocked: private repositories require a GRASP-08 private tenant")
500+ }
501+ return nil
502+}
503+
504+func hasLegacyPrivateRepository(ctx context.Context, store *storage.Store) (bool, error) {
505+ if store == nil {
506+ return false, errors.New("private repository scan: missing store")
507+ }
508+ return store.HasPrivateRepositories(ctx)
509+}
510+
511+func privateRepositoryStored(ctx context.Context, store *storage.Store, e event.Event) (bool, error) {
512+ if policy.IsPrivateRepository(e) {
513+ return true, nil
514+ }
515+ if store == nil || e.Kind != event.KIND_REPO_STATE {
516+ return false, nil
517+ }
518+ return store.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))
519+}
520diff --git a/internal/daemon/private_repository_test.go b/internal/daemon/private_repository_test.go
521new file mode 100644
522index 0000000..dab1e64
523--- /dev/null
524+++ b/internal/daemon/private_repository_test.go
525@@ -0,0 +1,139 @@
526+package daemon
527+
528+import (
529+ "context"
530+ "encoding/json"
531+ "strings"
532+ "testing"
533+ "time"
534+
535+ "github.com/FelineStateMachine/tinyrelay/internal/configport"
536+ "github.com/FelineStateMachine/tinyrelay/internal/event"
537+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
538+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
539+ "github.com/FelineStateMachine/tinyrelay/internal/replication"
540+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
541+)
542+
543+func TestPrivateRepositoryRejectedOnOpenTenantPublishAndImport(t *testing.T) {
544+ _, tenant := testTenant(t)
545+ p := tenant.Policy()
546+ p.Features.Grasp = true
547+ p.Reads = "open"
548+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
549+ t.Fatal(err)
550+ }
551+ secret := strings.Repeat("4", 64)
552+ owner, err := event.PublicKey(secret)
553+ if err != nil {
554+ t.Fatal(err)
555+ }
556+ e := event.Event{Kind: event.KIND_REPO, PubKey: owner, CreatedAt: 1, Tags: [][]string{{"d", "private"}, {"private", "true"}, {"clone", "https://relay.example/private.git"}}}
557+ if err := event.Sign(&e, secret); err != nil {
558+ t.Fatal(err)
559+ }
560+ if _, err := tenant.router.Publish(context.Background(), e, relay.Session{PubKeys: []string{owner}}); err == nil || !strings.Contains(err.Error(), "GRASP-08") {
561+ t.Fatalf("open publish error = %v", err)
562+ }
563+ if err := tenant.ingest(context.Background(), e, replication.OriginImport); err == nil || !strings.Contains(err.Error(), "GRASP-08") {
564+ t.Fatalf("open import error = %v", err)
565+ }
566+}
567+
568+func TestPrivateRepositoryPolicyCannotBeReopened(t *testing.T) {
569+ owner, err := event.PublicKey(strings.Repeat("5", 63) + "1")
570+ if err != nil {
571+ t.Fatal(err)
572+ }
573+ _, tenant, _ := privatePeerTenant(t, "private-policy", owner)
574+ e := event.Event{Kind: event.KIND_REPO, PubKey: owner, CreatedAt: 1, Tags: [][]string{{"d", "private"}, {"private", "true"}}}
575+ if err := event.Sign(&e, strings.Repeat("5", 63)+"1"); err != nil {
576+ t.Fatal(err)
577+ }
578+ if _, err := tenant.store.Save(context.Background(), e, storage.SaveOptions{Now: 1}); err != nil {
579+ t.Fatal(err)
580+ }
581+ p := tenant.Policy()
582+ p.Features.Grasp08 = false
583+ p.Reads = "open"
584+ if err := tenant.applyPolicy(context.Background(), p); err == nil {
585+ t.Fatal("private policy transition was accepted")
586+ }
587+ patch := configport.Config{Format: configport.Format, Policy: map[string]json.RawMessage{
588+ "features": json.RawMessage(`{"grasp08":false}`), "reads": json.RawMessage(`"open"`),
589+ }}
590+ if _, err := tenant.config.Apply(context.Background(), patch, false); err == nil {
591+ t.Fatal("config import reopened a private tenant")
592+ }
593+ var stored policy.Policy
594+ if err := tenant.store.GetSetting(context.Background(), "policy", &stored); err != nil {
595+ t.Fatal(err)
596+ }
597+ if !tenant.PrivateServiceEnabled() || !stored.Features.Grasp08 || stored.Reads != "members" {
598+ t.Fatal("rejected import changed the effective or durable private policy")
599+ }
600+}
601+
602+func TestLegacyPrivateRepositoryForcesBoundaryAfterRestart(t *testing.T) {
603+ ctx := context.Background()
604+ dir := t.TempDir()
605+ app, err := New(ctx, Config{DataDir: dir, DefaultTenant: "main"})
606+ if err != nil {
607+ t.Fatal(err)
608+ }
609+ secret := strings.Repeat("6", 63) + "1"
610+ owner, err := event.PublicKey(secret)
611+ if err != nil {
612+ t.Fatal(err)
613+ }
614+ meta, err := app.Create(ctx, CreateOptions{Name: "main", Owner: owner, Template: "default"})
615+ if err != nil {
616+ t.Fatal(err)
617+ }
618+ tenant, err := app.tenant(ctx, meta, "http://relay.test")
619+ if err != nil {
620+ t.Fatal(err)
621+ }
622+ now := time.Now().Unix()
623+ items := []event.Event{
624+ {Kind: event.KIND_REPO, PubKey: owner, CreatedAt: now, Tags: [][]string{{"d", "legacy"}, {"private", "true"}}},
625+ {Kind: event.KIND_REPO_STATE, PubKey: owner, CreatedAt: now + 1, Tags: [][]string{{"d", "legacy"}}},
626+ {Kind: 1621, PubKey: owner, CreatedAt: now + 2, Tags: [][]string{{"e", "legacy"}}},
627+ }
628+ for i := range items {
629+ if err := event.Sign(&items[i], secret); err != nil {
630+ t.Fatal(err)
631+ }
632+ if _, err := tenant.store.Save(ctx, items[i], storage.SaveOptions{Now: now}); err != nil {
633+ t.Fatal(err)
634+ }
635+ }
636+ if err := app.Close(ctx); err != nil {
637+ t.Fatal(err)
638+ }
639+ app, err = New(ctx, Config{DataDir: dir, DefaultTenant: "main"})
640+ if err != nil {
641+ t.Fatal(err)
642+ }
643+ if err := app.Start(ctx, "http://relay.test"); err != nil {
644+ t.Fatal(err)
645+ }
646+ t.Cleanup(func() { _ = app.Close(ctx) })
647+ reloaded := app.tenants[meta.ID]
648+ if reloaded == nil || !reloaded.PrivateServiceEnabled() || reloaded.Policy().Reads != "members" {
649+ t.Fatal("legacy private data did not force members boundary")
650+ }
651+ if _, err := reloaded.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_REPO, event.KIND_REPO_STATE, 1621}}}, relay.Session{}); err == nil {
652+ t.Fatal("anonymous query reached legacy private metadata")
653+ }
654+ if _, err := reloaded.Count(ctx, []event.Filter{{Kinds: []int{event.KIND_REPO}}}, relay.Session{}); err == nil {
655+ t.Fatal("anonymous count reached legacy private metadata")
656+ }
657+ var durable policy.Policy
658+ if err := reloaded.store.GetSetting(ctx, "policy", &durable); err != nil {
659+ t.Fatal(err)
660+ }
661+ if !durable.Features.Grasp08 || durable.Reads != "members" {
662+ t.Fatal("legacy boundary must remain private after its announcement is removed")
663+ }
664+}
665diff --git a/internal/daemon/services.go b/internal/daemon/services.go
666index 19c124c..b31b3b7 100644
667--- a/internal/daemon/services.go
668+++ b/internal/daemon/services.go
669@@ -100,7 +100,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
670 if err != nil {
671 return err
672 }
673- t.config = configport.New(configport.ConfigStore{Store: t.store, Community: t.community, Policy: t.Policy, OnApplied: func(p policy.Policy) { t.replacePolicy(p) }})
674+ t.config = configport.New(configport.ConfigStore{Store: t.store, Community: t.community, Policy: t.Policy, ValidatePolicy: t.validatePolicyTransition, OnApplied: func(p policy.Policy) { t.replacePolicy(p) }})
675 transport := &replication.NostrTransport{Dialer: replication.WebsocketDialer{AllowPrivate: t.app.cfg.AllowPrivateRelays, MaxMessageBytes: t.app.cfg.MaxMessageBytes}}
676 t.replication, err = replication.NewService(replication.Config{Store: t.store, Owner: func() string { return t.Policy().Owner }, Policy: replication.Policy{Enabled: t.Policy().Delivery.Enabled, SelfPubKey: t.records.PublicKey()}, CurrentPolicy: func() replication.Policy {
677 p := t.Policy()
678@@ -378,7 +378,7 @@ func (t *Tenant) authorizeGit(ctx context.Context, r *http.Request, repo gitrela
679 return errors.New("restricted: repository authorization required")
680 }
681 }
682- if _, spooled := r.Context().Value(privateGitSpoolKey{}).(bool); spooled {
683+ if _, checked := r.Context().Value(privateGitPayloadCheckedKey{}).(struct{}); checked {
684 return nil
685 }
686 return spoolGitPayload(r, e)
687@@ -605,6 +605,9 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
688 if err := t.gate.Import(ctx, e, time.Now().Unix()); err != nil {
689 return err
690 }
691+ if err := t.validatePrivateRepositoryPlacement(ctx, e); err != nil {
692+ return err
693+ }
694 if t.sites != nil {
695 if err := sites.ValidateManifest(e); err != nil {
696 return err
697diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
698index 278a905..24abbcd 100644
699--- a/internal/daemon/tenant.go
700+++ b/internal/daemon/tenant.go
701@@ -5,6 +5,7 @@ import (
702 "database/sql"
703 "encoding/json"
704 "errors"
705+ "fmt"
706 "net/http"
707 "sort"
708 "strings"
709@@ -66,8 +67,22 @@ type Tenant struct {
710 }
711
712 func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {
713- t := &Tenant{app: cfg.app, meta: cfg.meta, store: cfg.store, policy: cfg.policy, publicURL: cfg.publicURL, auth: auth.NewValidator(time.Now), schedulerWake: make(chan struct{}, 1)}
714- var err error
715+ p := cfg.policy
716+ legacyPrivate, err := hasLegacyPrivateRepository(ctx, cfg.store)
717+ if err != nil {
718+ return nil, err
719+ }
720+ if legacyPrivate && (!p.Features.Grasp08 || p.Reads != "members") {
721+ // Keep the private boundary durable even if an announcement is later
722+ // removed while its repository or collaboration data remains.
723+ p.Features.Grasp = true
724+ p.Features.Grasp08 = true
725+ p.Reads = "members"
726+ if err := cfg.store.WithTx(ctx, func(tx *sql.Tx) error { return storage.PutSetting(ctx, tx, "policy", p) }); err != nil {
727+ return nil, fmt.Errorf("preserve legacy repository privacy: %w", err)
728+ }
729+ }
730+ t := &Tenant{app: cfg.app, meta: cfg.meta, store: cfg.store, policy: p, publicURL: cfg.publicURL, auth: auth.NewValidator(time.Now), schedulerWake: make(chan struct{}, 1)}
731 t.community, err = community.New(ctx, t.store, t.policy.Owner)
732 if err != nil {
733 return nil, err
734@@ -149,6 +164,9 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
735 if err := t.gate.Write(ctx, e, s, now); err != nil {
736 return "", err
737 }
738+ if err := t.validatePrivateRepositoryPlacement(ctx, e); err != nil {
739+ return "", err
740+ }
741 if err := t.validatePushRegistration(ctx, e, s); err != nil {
742 return "", err
743 }
744@@ -567,8 +585,19 @@ func (t *Tenant) applyPolicy(ctx context.Context, next policy.Policy) error {
745 return t.persistPolicy(ctx, next)
746 }
747
748+func (t *Tenant) validatePolicyTransition(next policy.Policy) error {
749+ previous := t.Policy()
750+ if previous.Features.Grasp08 && previous.Reads == "members" && (!next.Features.Grasp08 || next.Reads != "members") {
751+ return errors.New("blocked: opening a private GRASP-08 tenant requires an explicit data migration")
752+ }
753+ return nil
754+}
755+
756 func (t *Tenant) persistPolicy(ctx context.Context, next policy.Policy) error {
757 previous := t.Policy()
758+ if err := t.validatePolicyTransition(next); err != nil {
759+ return err
760+ }
761 err := t.store.WithTx(ctx, func(tx *sql.Tx) error {
762 if next.Owner != previous.Owner {
763 if err := t.community.ApplyOwnerTx(ctx, tx, previous.Owner, next.Owner); err != nil {
764diff --git a/internal/gates/gates.go b/internal/gates/gates.go
765index 9769a08..fb1a02c 100644
766--- a/internal/gates/gates.go
767+++ b/internal/gates/gates.go
768@@ -465,6 +465,10 @@ func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *ev
769 return false
770 }
771 }
772+ private, err := privateRepositoryEvent(ctx, g.cfg.Store, e)
773+ if err != nil || private && (!p.Features.Grasp08 || p.Reads != "members") {
774+ return false
775+ }
776 if g.cfg.Community != nil {
777 banned, err := g.cfg.Community.IsEventBanned(ctx, e.ID)
778 if err != nil || banned {
779@@ -507,6 +511,19 @@ func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *ev
780 return true
781 }
782
783+// privateRepositoryEvent also protects legacy state events. Their privacy is
784+// inherited from the corresponding replaceable announcement, so checking only
785+// the state event's own tags would allow metadata to escape after a restart.
786+func privateRepositoryEvent(ctx context.Context, store *storage.Store, e event.Event) (bool, error) {
787+ if policy.IsPrivateRepository(e) {
788+ return true, nil
789+ }
790+ if store == nil || e.Kind != event.KIND_REPO_STATE {
791+ return false, nil
792+ }
793+ return store.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))
794+}
795+
796 func canSeeSignerMessage(e event.Event, s relay.Session, f *event.Filter) bool {
797 if contains(s.PubKeys, e.PubKey) {
798 return true
799diff --git a/internal/gitrelay/capabilities_test.go b/internal/gitrelay/capabilities_test.go
800new file mode 100644
801index 0000000..e4a33b6
802--- /dev/null
803+++ b/internal/gitrelay/capabilities_test.go
804@@ -0,0 +1,34 @@
805+package gitrelay
806+
807+import (
808+ "context"
809+ "reflect"
810+ "testing"
811+
812+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
813+)
814+
815+func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {
816+ p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true}}
817+ g := &GitRelay{policy: func() policy.Policy { return p }}
818+ if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {
819+ t.Fatalf("without event transport: %v", got)
820+ }
821+ g.eventSync = func(context.Context, Repository) error { return nil }
822+ if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06"}) {
823+ t.Fatalf("with event transport: %v", got)
824+ }
825+ p.Features.Grasp02 = false
826+ if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {
827+ t.Fatalf("without base sync: %v", got)
828+ }
829+}
830+
831+func TestCapabilitiesPreservePrivateProfiles(t *testing.T) {
832+ p := policy.Policy{Reads: "members", Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true, Grasp08: true}}
833+ g := &GitRelay{policy: func() policy.Policy { return p }, eventSync: func(context.Context, Repository) error { return nil }}
834+ want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}
835+ if got := g.SupportedGRASPs(); !reflect.DeepEqual(got, want) {
836+ t.Fatalf("private profiles: %v", got)
837+ }
838+}
839diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go
840index b6f2d5e..e15c2e1 100644
841--- a/internal/gitrelay/gitrelay.go
842+++ b/internal/gitrelay/gitrelay.go
843@@ -314,10 +314,12 @@ func repoFingerprint(r Repository, profile policy.Policy) string {
844 }
845 sort.Strings(refs)
846 profileJSON, _ := json.Marshal(struct {
847+ Grasp bool `json:"grasp"`
848+ Reads string `json:"reads"`
849 Grasp02 bool `json:"grasp02"`
850 Grasp03 bool `json:"grasp03"`
851 Peers []string `json:"peers"`
852- }{profile.Features.Grasp02, profile.Features.Grasp03, profile.PrivatePeers})
853+ }{profile.Features.Grasp, profile.Reads, profile.Features.Grasp02, profile.Features.Grasp03, profile.PrivatePeers})
854 parts := []string{r.Owner, r.Identifier, r.EventID, r.Head, strconv.FormatBool(r.Private), strings.Join(refs, "\x00"), strings.Join(r.Clone, "\x00"), strings.Join(r.Relays, "\x00"), strings.Join(r.Maintainers, "\x00"), string(profileJSON)}
855 sum := sha256.Sum256([]byte(strings.Join(parts, "\x01")))
856 return hex.EncodeToString(sum[:])
857@@ -917,12 +919,21 @@ func (g *GitRelay) validateRefs(refs map[string]string) error {
858 }
859
860 // Capabilities returns only protocol surfaces that this instance can serve.
861-// GRASP-02 is an operator-triggered fetch API; GRASP-06 is explicitly opt-in.
862 func (g *GitRelay) Capabilities() []string {
863+ p := g.policy()
864 c := []string{"GRASP-01"}
865- if g.grasp06 || g.policy().Features.Grasp06 {
866+ if p.Features.Grasp02 && g.eventSync != nil {
867+ c = append(c, "GRASP-02")
868+ if p.Features.Grasp03 {
869+ c = append(c, "GRASP-03")
870+ }
871+ }
872+ if g.grasp06 || p.Features.Grasp06 {
873 c = append(c, "GRASP-06")
874 }
875+ if p.Features.Grasp && p.Features.Grasp08 && p.Reads == "members" {
876+ c = append(c, "GRASP-08")
877+ }
878 return c
879 }
880
881@@ -1232,6 +1243,12 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
882 }
883 }
884 for _, raw := range sources {
885+ // Private object IDs must never be requested from a public source.
886+ private := r.Private || (g.policy != nil && g.policy().Reads == "members")
887+ if private && !g.privatePeer(raw) {
888+ lastErr = errors.New("blocked: private Git source is not a configured peer")
889+ continue
890+ }
891 source, ip, err := g.resolvedSource(ctx, raw)
892 if err != nil {
893 continue
894@@ -1241,7 +1258,10 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
895 if err := probePrivatePeer(ctx, peerBase, g.allowPrivate); err != nil {
896 continue
897 }
898- proxy, err = newPrivateProxy(ctx, source, g.httpAuth, g.allowPrivate)
899+ g.mu.RLock()
900+ signer := g.httpAuth
901+ g.mu.RUnlock()
902+ proxy, err = newPrivateProxy(ctx, source, signer, g.allowPrivate)
903 if err != nil {
904 continue
905 }
906@@ -1259,7 +1279,16 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
907 fetchSource = proxy.URL()
908 resolve = nil
909 }
910- args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=", "--git-dir", g.repoPath(r), "fetch", "--no-tags", fetchSource, "+" + ref + ":" + "refs/tinyrelay/source/" + safeRef(ref)}
911+ fetchRef := ref
912+ if strings.HasPrefix(ref, "refs/nostr/") {
913+ // NIP-34 clone URLs may point to ordinary Git hosts. The
914+ // signed commit is the source; refs/nostr is our local name.
915+ if !isObjectID(oid) {
916+ return errors.New("invalid: PR commit ID")
917+ }
918+ fetchRef = oid
919+ }
920+ args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=", "--git-dir", g.repoPath(r), "fetch", "--no-tags", fetchSource, "+" + fetchRef + ":" + "refs/tinyrelay/source/" + safeRef(ref)}
921 // Keep the resolve option before the subcommand; Git accepts config
922 // options only before the command name.
923 if len(resolve) > 0 {
924diff --git a/internal/gitrelay/private_transport.go b/internal/gitrelay/private_transport.go
925index 11ecd34..d141cdd 100644
926--- a/internal/gitrelay/private_transport.go
927+++ b/internal/gitrelay/private_transport.go
928@@ -14,11 +14,13 @@ import (
929 "net/url"
930 "os"
931 "strings"
932+
933+ "github.com/FelineStateMachine/tinyrelay/internal/auth"
934 )
935
936-// HTTPAuthSigner signs one complete HTTP request. The payload hash is empty
937-// for requests without a body. It is deliberately a callback so GitRelay
938-// never owns or handles a tenant's private key.
939+// HTTPAuthSigner returns the authorization header for a private peer request.
940+// GRASP-08 callers receive the repository root, GET and an empty payload hash;
941+// it is deliberately a callback so GitRelay never owns private keys.
942 type HTTPAuthSigner func(context.Context, string, string, string) (string, error)
943
944 // ConfigurePrivatePeers refreshes operator-controlled private transport after
945@@ -103,7 +105,7 @@ func (p *privateProxy) serve(w http.ResponseWriter, r *http.Request) {
946 }
947 u.Path = strings.TrimSuffix(p.base.Path, "/") + "/" + strings.TrimPrefix(suffix, "/")
948 u.RawQuery = r.URL.RawQuery
949- file, hash, size, err := spoolRequest(r.Body)
950+ file, _, size, err := spoolRequest(r.Body)
951 if err != nil {
952 http.Error(w, err.Error(), http.StatusBadGateway)
953 return
954@@ -111,11 +113,14 @@ func (p *privateProxy) serve(w http.ResponseWriter, r *http.Request) {
955 if file != nil {
956 defer func() { _ = os.Remove(file.Name()); _ = file.Close() }()
957 }
958- payload := ""
959- if size > 0 {
960- payload = hex.EncodeToString(hash[:])
961+ signURL, ok := auth.GRASP08RepositoryRoot(u.String())
962+ if !ok {
963+ http.Error(w, "private peer: invalid Git repository URL", http.StatusBadGateway)
964+ return
965 }
966- authorization, err := p.sign(r.Context(), r.Method, u.String(), payload)
967+ // GRASP-08 uses one reusable GET proof for the repository root. The
968+ // concrete Smart HTTP path and request body are deliberately out of scope.
969+ authorization, err := p.sign(r.Context(), http.MethodGet, signURL, "")
970 if err != nil {
971 http.Error(w, err.Error(), http.StatusBadGateway)
972 return
973@@ -213,7 +218,10 @@ func (g *GitRelay) privatePeerBase(source string) string {
974 if err != nil {
975 return ""
976 }
977- for _, configured := range g.privatePeers {
978+ g.mu.RLock()
979+ peers := g.privatePeers
980+ g.mu.RUnlock()
981+ for _, configured := range peers {
982 peer, err := url.Parse(strings.TrimRight(strings.TrimSpace(configured), "/"))
983 if err != nil || normalizePeerScheme(peer.Scheme) != normalizePeerScheme(u.Scheme) || peer.Host != u.Host {
984 continue
985diff --git a/internal/gitrelay/private_transport_test.go b/internal/gitrelay/private_transport_test.go
986index b3d932e..1d83917 100644
987--- a/internal/gitrelay/private_transport_test.go
988+++ b/internal/gitrelay/private_transport_test.go
989@@ -20,8 +20,8 @@ func TestPrivateProxySignsAndStreamsGitRequest(t *testing.T) {
990 _, _ = w.Write([]byte("pack-response"))
991 }))
992 t.Cleanup(remote.Close)
993- proxy, err := newPrivateProxy(context.Background(), remote.URL+"/r/private", func(_ context.Context, method, target, payload string) (string, error) {
994- if method != http.MethodPost || !strings.HasSuffix(target, "/r/private/info/refs") {
995+ proxy, err := newPrivateProxy(context.Background(), remote.URL+"/r/owner/repo.git", func(_ context.Context, method, target, payload string) (string, error) {
996+ if method != http.MethodGet || target != remote.URL+"/r/owner/repo.git" || payload != "" {
997 return "", &testError{"unexpected signed request"}
998 }
999 return "Nostr signed:" + payload, nil
1000diff --git a/internal/policy/access.go b/internal/policy/access.go
1001index 5778515..afa16cf 100644
1002--- a/internal/policy/access.go
1003+++ b/internal/policy/access.go
1004@@ -20,12 +20,30 @@ func CanRead(p Policy, e event.Event, a Access) bool {
1005 if !readRule(p, a) {
1006 return false
1007 }
1008+ if IsPrivateRepository(e) && (!p.Features.Grasp08 || p.Reads != "members") {
1009+ return false
1010+ }
1011 if privateKind(e.Kind) {
1012 return addressed(e, a)
1013 }
1014 return true
1015 }
1016
1017+// IsPrivateRepository identifies a GRASP repository announcement carrying the
1018+// private=true marker. State events are associated with their announcement by
1019+// owner and identifier at the storage boundary.
1020+func IsPrivateRepository(e event.Event) bool {
1021+ if e.Kind != 30617 {
1022+ return false
1023+ }
1024+ for _, tag := range e.Tags {
1025+ if len(tag) >= 2 && tag[0] == "private" && strings.EqualFold(tag[1], "true") {
1026+ return true
1027+ }
1028+ }
1029+ return false
1030+}
1031+
1032 func CanWrite(p Policy, e event.Event, a Access) bool {
1033 if containsInt(p.BlockedKinds, e.Kind) {
1034 return false
1035diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go
1036index 1b0b6ad..d73b25b 100644
1037--- a/internal/policy/policy_test.go
1038+++ b/internal/policy/policy_test.go
1039@@ -71,6 +71,21 @@ func TestGRASP08RequiresBaseGRASP(t *testing.T) {
1040 }
1041 }
1042
1043+func TestPrivateRepositoryRequiresGRASP08PrivateTenant(t *testing.T) {
1044+ e := event.Event{Kind: 30617, Tags: [][]string{{"private", "true"}}}
1045+ if CanRead(Policy{Reads: "open"}, e, Access{}) {
1046+ t.Fatal("private repository was readable on an open tenant")
1047+ }
1048+ p := Policy{Reads: "members"}
1049+ if !CanRead(p, event.Event{Kind: 1}, Access{Member: true}) {
1050+ t.Fatal("ordinary event became unreadable on a member tenant")
1051+ }
1052+ p.Features.Grasp08 = true
1053+ if !CanRead(p, e, Access{Member: true}) {
1054+ t.Fatal("private repository was unreadable on a GRASP-08 member tenant")
1055+ }
1056+}
1057+
1058 func TestGRASPHistoryExtensionsRequireGRASP02(t *testing.T) {
1059 cases := []struct {
1060 name string
1061diff --git a/internal/replication/delivery.go b/internal/replication/delivery.go
1062index b255829..5f26338 100644
1063--- a/internal/replication/delivery.go
1064+++ b/internal/replication/delivery.go
1065@@ -6,6 +6,7 @@ import (
1066 "fmt"
1067
1068 "github.com/FelineStateMachine/tinyrelay/internal/event"
1069+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
1070 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1071 )
1072
1073@@ -29,7 +30,11 @@ func deliver(ctx context.Context, store *storage.Store, transport DeliveryTransp
1074 return nil
1075 }
1076 e := result.Events[0]
1077- if privateKind(e.Kind) || hasProtectedTag(e) {
1078+ private, privateErr := privateRepository(ctx, store, e)
1079+ if privateErr != nil {
1080+ return fmt.Errorf("replication: check repository privacy: %w", privateErr)
1081+ }
1082+ if privateKind(e.Kind) || hasProtectedTag(e) || private {
1083 return nil
1084 }
1085 if e.Kind != 10002 {
1086@@ -44,6 +49,16 @@ func deliver(ctx context.Context, store *storage.Store, transport DeliveryTransp
1087 return sendAccepted(ctx, transport, intent.Target, e)
1088 }
1089
1090+func privateRepository(ctx context.Context, store *storage.Store, e event.Event) (bool, error) {
1091+ if policy.IsPrivateRepository(e) {
1092+ return true, nil
1093+ }
1094+ if store == nil || e.Kind != event.KIND_REPO_STATE {
1095+ return false, nil
1096+ }
1097+ return store.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))
1098+}
1099+
1100 func authorRelayList(ctx context.Context, store *storage.Store, e event.Event) (*event.Event, error) {
1101 result, err := store.Query(ctx, event.Filter{Authors: []string{e.PubKey}, Kinds: []int{10002}, Tags: map[string][]string{}}, storage.QueryOptions{Now: 0, Access: storage.Access{All: true}, Limit: 1})
1102 if err != nil {
1103diff --git a/internal/replication/private_delivery_test.go b/internal/replication/private_delivery_test.go
1104new file mode 100644
1105index 0000000..603074d
1106--- /dev/null
1107+++ b/internal/replication/private_delivery_test.go
1108@@ -0,0 +1,70 @@
1109+package replication
1110+
1111+import (
1112+ "context"
1113+ "strings"
1114+ "testing"
1115+
1116+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1117+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
1118+)
1119+
1120+func TestQueuedPrivateRepositoryMetadataIsNotDelivered(t *testing.T) {
1121+ store := openReplicationStore(t)
1122+ ctx := context.Background()
1123+ transport := &recordingTransport{}
1124+ handler := NewDeliveryHandler(store, transport)
1125+ for _, kind := range []int{event.KIND_REPO, event.KIND_REPO_STATE} {
1126+ e := signedEvent(t, 1)
1127+ e.Kind = kind
1128+ e.Tags = [][]string{{"d", "private"}}
1129+ if kind == event.KIND_REPO {
1130+ e.Tags = append(e.Tags, []string{"private", "true"})
1131+ }
1132+ if err := event.Sign(&e, strings.Repeat("1", 64)); err != nil {
1133+ t.Fatal(err)
1134+ }
1135+ if _, err := store.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt}); err != nil {
1136+ t.Fatal(err)
1137+ }
1138+ if err := handler(ctx, storage.Intent{Kind: "delivery", EventID: e.ID, Target: "wss://public.example"}); err != nil {
1139+ t.Fatal(err)
1140+ }
1141+ }
1142+ if len(transport.events) != 0 {
1143+ t.Fatal("private metadata escaped through a queued public delivery")
1144+ }
1145+}
1146+
1147+func TestPrivateScopeLookupFailureRetainsDeliveryForRetry(t *testing.T) {
1148+ store := openReplicationStore(t)
1149+ ctx := context.Background()
1150+ e := signedEvent(t, 1)
1151+ e.Kind = event.KIND_REPO_STATE
1152+ e.Tags = [][]string{{"d", "private"}}
1153+ if err := event.Sign(&e, strings.Repeat("1", 64)); err != nil {
1154+ t.Fatal(err)
1155+ }
1156+ if _, err := store.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt}); err != nil {
1157+ t.Fatal(err)
1158+ }
1159+ announcement := e
1160+ announcement.Kind = event.KIND_REPO
1161+ if err := event.Sign(&announcement, strings.Repeat("1", 64)); err != nil {
1162+ t.Fatal(err)
1163+ }
1164+ if _, err := store.Save(ctx, announcement, storage.SaveOptions{Now: announcement.CreatedAt}); err != nil {
1165+ t.Fatal(err)
1166+ }
1167+ if _, err := store.DB().ExecContext(ctx, "UPDATE events SET raw='invalid JSON' WHERE id=?", announcement.ID); err != nil {
1168+ t.Fatal(err)
1169+ }
1170+ transport := &recordingTransport{}
1171+ err := NewDeliveryHandler(store, transport)(ctx, storage.Intent{Kind: "delivery", EventID: e.ID, Target: "wss://public.example"})
1172+ if err == nil {
1173+ t.Fatal("privacy lookup failure was acknowledged instead of retained for retry")
1174+ }
1175+ if len(transport.events) != 0 {
1176+ t.Fatal("delivery proceeded after a privacy lookup failure")
1177+ }
1178+}
1179diff --git a/internal/storage/private_repository.go b/internal/storage/private_repository.go
1180new file mode 100644
1181index 0000000..d0d0391
1182--- /dev/null
1183+++ b/internal/storage/private_repository.go
1184@@ -0,0 +1,36 @@
1185+package storage
1186+
1187+import (
1188+ "context"
1189+ "fmt"
1190+
1191+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1192+)
1193+
1194+// HasPrivateRepositories checks the stored announcements, including legacy
1195+// events. Multiword tags such as private are not in the NIP-01 tag index.
1196+func (s *Store) HasPrivateRepositories(ctx context.Context) (bool, error) {
1197+ return s.hasPrivateRepository(ctx, "", "")
1198+}
1199+
1200+// IsPrivateRepository resolves a state event's privacy from its announcement.
1201+func (s *Store) IsPrivateRepository(ctx context.Context, pubkey, identifier string) (bool, error) {
1202+ return s.hasPrivateRepository(ctx, pubkey, identifier)
1203+}
1204+
1205+func (s *Store) hasPrivateRepository(ctx context.Context, pubkey, identifier string) (bool, error) {
1206+ query := `SELECT EXISTS(SELECT 1 FROM events e WHERE e.kind=?`
1207+ args := []any{event.KIND_REPO}
1208+ if pubkey != "" {
1209+ query += ` AND e.pubkey=? AND e.d=?`
1210+ args = append(args, pubkey, identifier)
1211+ }
1212+ query += ` AND EXISTS(SELECT 1 FROM json_each(e.raw, '$.tags') tag
1213+ WHERE json_extract(tag.value, '$[0]')='private'
1214+ AND lower(json_extract(tag.value, '$[1]'))='true'))`
1215+ var found bool
1216+ if err := s.DB().QueryRowContext(ctx, query, args...).Scan(&found); err != nil {
1217+ return false, fmt.Errorf("check stored repository privacy: %w", err)
1218+ }
1219+ return found, nil
1220+}
1221diff --git a/internal/webui/components.js b/internal/webui/components.js
1222index af07cd6..acb5372 100644
1223--- a/internal/webui/components.js
1224+++ b/internal/webui/components.js
1225@@ -23,7 +23,8 @@
1226 // <file-tools [hash="…"] [type="…"]>
1227 // Copies Blossom links, encrypts and uploads a file, decrypts a share
1228 // link fragment and sends a random-key file as a NIP-17 message.
1229-// <file-workspace> and <file-workspace-root> live in file-workspace.js.
1230+// <file-workspace> and <file-workspace-root> live in file-workspace.js;
1231+// <private-services> lives in private-services.js.
1232 (() => {
1233 "use strict";
1234
1235diff --git a/internal/webui/manage.html b/internal/webui/manage.html
1236index b3a04e4..61e2ce7 100644
1237--- a/internal/webui/manage.html
1238+++ b/internal/webui/manage.html
1239@@ -112,6 +112,13 @@
1240 </details>
1241 <h3>Publish signed relay records</h3>
1242 <p>These actions open the configured signer and publish owner-authored Nostr records.</p>
1243+<private-services>
1244+<h3>Private Git relay list</h3>
1245+<p>GRASP-08 stores private relay URLs in your encrypted kind 10318 list. The relay URLs and any list entries are encrypted to your signer with NIP-44.</p>
1246+<form><label>Private relay URLs, one per line <textarea name="lines" placeholder="wss://private-relay.example"></textarea></label><button disabled>Publish encrypted list</button></form>
1247+<noscript><p>Connect a signer and enable JavaScript to encrypt your private service list.</p></noscript>
1248+<output role="status"></output>
1249+</private-services>
1250 <publish-list kind="10317" tag="g" scheme="ws">
1251 <label>GRASP relay URLs, one per line <textarea name="lines" placeholder="wss://relay.example&#10;wss://backup.example"></textarea></label>
1252 <button>Publish GRASP list</button>
1253diff --git a/internal/webui/page.html b/internal/webui/page.html
1254index 5659f1c..83135c9 100644
1255--- a/internal/webui/page.html
1256+++ b/internal/webui/page.html
1257@@ -62,7 +62,13 @@
1258 <script>{{script "blossom-upload.js"}}</script>
1259 <script>{{script "file-messages.js"}}</script>
1260 <script>{{script "file-workspace.js"}}</script>
1261+<script>{{script "blossom-encryption.js"}}</script>
1262+<script>{{script "blossom-manifests.js"}}</script>
1263+<script>{{script "blossom-upload.js"}}</script>
1264+<script>{{script "file-messages.js"}}</script>
1265+<script>{{script "file-workspace.js"}}</script>
1266 <script>{{script "components.js"}}</script>
1267+<script>{{script "private-services.js"}}</script>
1268 </body>
1269 </html>
1270 {{end}}
1271diff --git a/internal/webui/private-services.js b/internal/webui/private-services.js
1272new file mode 100644
1273index 0000000..78733f4
1274--- /dev/null
1275+++ b/internal/webui/private-services.js
1276@@ -0,0 +1,135 @@
1277+// PrivateServices edits the encrypted GRASP-08 kind 10318 list. The relay
1278+// only sees the signed event; NIP-44 encryption stays with the user's signer.
1279+(() => {
1280+ "use strict";
1281+
1282+ const validURL = value => {
1283+ try {
1284+ const parsed = new URL(String(value).trim());
1285+ return (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&
1286+ parsed.hostname !== "" && !parsed.username && !parsed.password &&
1287+ parsed.search === "" && parsed.hash === "";
1288+ } catch { return false; }
1289+ };
1290+ const relay = value => {
1291+ const parsed = new URL(String(value || "").trim());
1292+ parsed.hostname = parsed.hostname.toLowerCase();
1293+ if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/+$/, "");
1294+ return parsed.toString().replace(/\/$/, "");
1295+ };
1296+ const mergeRows = (rows, urls) => rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));
1297+ const signer = () => window.tinySigner || window.nostr;
1298+ const publicKey = async () => {
1299+ const active = signer();
1300+ if (active?.getPublicKey) return active.getPublicKey();
1301+ if (window.tiny?.getPublicKey) return window.tiny.getPublicKey();
1302+ throw Error("Connect a signer first.");
1303+ };
1304+ const encrypt = async (pubkey, text) => {
1305+ const active = signer();
1306+ if (active?.nip44Encrypt) return active.nip44Encrypt(pubkey, text);
1307+ if (active?.nip44?.encrypt) return active.nip44.encrypt(pubkey, text);
1308+ if (window.tiny?.nip44Encrypt) return window.tiny.nip44Encrypt(pubkey, text);
1309+ throw Error("The connected signer does not support NIP-44.");
1310+ };
1311+ const decrypt = async (pubkey, text) => {
1312+ const active = signer();
1313+ if (active?.nip44Decrypt) return active.nip44Decrypt(pubkey, text);
1314+ if (active?.nip44?.decrypt) return active.nip44.decrypt(pubkey, text);
1315+ if (window.tiny?.nip44Decrypt) return window.tiny.nip44Decrypt(pubkey, text);
1316+ throw Error("The connected signer does not support NIP-44.");
1317+ };
1318+ const readList = async pubkey => {
1319+ const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);
1320+ const response = await window.tiny.signedFetch("/query", "POST", body, {contentType: "application/json"});
1321+ if (!response || response.ok === false) throw Error("Could not read the private relay list.");
1322+ const rows = await response.json();
1323+ if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");
1324+ const event = rows[0];
1325+ if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid.");
1326+ return event;
1327+ };
1328+ const acceptEvent = (event, expectedPubkey) => {
1329+ if (!event || event.kind !== 10318 || event.pubkey !== expectedPubkey || !Array.isArray(event.tags) || typeof event.content !== "string") return false;
1330+ if (typeof window.NostrSigner?.verifyEvent !== "function") return false;
1331+ try { return window.NostrSigner.verifyEvent(event) === true; } catch { return false; }
1332+ };
1333+ const publish = async event => {
1334+ const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
1335+ const text = await response.text();
1336+ let value;
1337+ try { value = JSON.parse(text); } catch { value = undefined; }
1338+ if (value?.error || value?.accepted === false) throw Error(value.error || value.message || "The relay rejected the event.");
1339+ };
1340+ const wireEvent = content => ({kind: 10318, created_at: Math.floor(Date.now() / 1000), tags: [], content});
1341+
1342+ class PrivateServices extends HTMLElement {
1343+ connectedCallback() {
1344+ if (this.bound) return;
1345+ this.bound = true;
1346+ this.form = this.querySelector("form");
1347+ this.lines = this.querySelector("textarea[name=lines]");
1348+ this.output = this.querySelector("output");
1349+ this.form?.addEventListener("submit", event => {
1350+ event.preventDefault();
1351+ if (this.busy) return;
1352+ this.busy = true;
1353+ this.form.querySelector("button").disabled = true;
1354+ this.save().catch(error => this.say("Error: " + error.message, true)).finally(() => {
1355+ this.busy = false;
1356+ this.form.querySelector("button").disabled = false;
1357+ });
1358+ });
1359+ this.whenSigned(() => this.load().then(() => { this.form.querySelector("button").disabled = false; }).catch(error => this.say("Error: " + error.message, true)));
1360+ }
1361+
1362+ whenSigned(fn, tries = 20) {
1363+ if (signer()?.signEvent || window.nostr?.signEvent) return fn();
1364+ if (tries <= 0) { this.say("Connect a signer, then reload this page."); return; }
1365+ setTimeout(() => this.whenSigned(fn, tries - 1), 500);
1366+ }
1367+
1368+ say(text, error) {
1369+ this.output.textContent = text;
1370+ if (error) this.output.dataset.error = "";
1371+ else delete this.output.dataset.error;
1372+ }
1373+
1374+ async load() {
1375+ this.say("Reading encrypted list…");
1376+ const pubkey = await publicKey();
1377+ const event = await readList(pubkey);
1378+ if (!event) { this.say("No private relay list published yet."); return; }
1379+ const rows = JSON.parse(await decrypt(pubkey, event.content));
1380+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");
1381+ const urls = rows.filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1])).map(row => relay(row[1]));
1382+ this.lines.value = urls.join("\n");
1383+ this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s") + ".");
1384+ }
1385+
1386+ async save() {
1387+ const rawURLs = this.lines.value.split(/\r?\n/).map(value => value.trim()).filter(Boolean);
1388+ if (rawURLs.some(value => !validURL(value))) throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");
1389+ const urls = rawURLs.map(relay);
1390+ const unique = [...new Set(urls)];
1391+ const pubkey = await publicKey();
1392+ const existing = await readList(pubkey);
1393+ let rows = [];
1394+ if (existing) {
1395+ rows = JSON.parse(await decrypt(pubkey, existing.content));
1396+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");
1397+ }
1398+ rows = mergeRows(rows, unique);
1399+ const content = await encrypt(pubkey, JSON.stringify(rows));
1400+ const active = signer();
1401+ if (!active?.signEvent) throw Error("Connect a signer first.");
1402+ const event = await active.signEvent(wireEvent(content));
1403+ if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0) throw Error("The signer returned an invalid private relay list event.");
1404+ await publish(event);
1405+ this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s") + ".");
1406+ }
1407+ }
1408+
1409+ window.PrivateServices = {validURL, canonicalURL: relay, mergeRows, encrypt, decrypt, wireEvent, acceptEvent, readList};
1410+ customElements.define("private-services", PrivateServices);
1411+})();
1412diff --git a/internal/webui/style.css b/internal/webui/style.css
1413index d3d6953..cd4959e 100644
1414--- a/internal/webui/style.css
1415+++ b/internal/webui/style.css
1416@@ -112,7 +112,7 @@ rpc-form label,signed-form label,publish-list label,file-tools label,file-worksp
1417 rpc-form label input,rpc-form label select,signed-form label input,file-tools label input,file-tools label select,file-workspace label input,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
1418 file-tools label:has([data-share-url]),file-workspace label:has([data-tree-share]){display:block}file-tools input[data-share-url],file-workspace input[data-tree-share]{width:100%}
1419 rpc-form label:has(textarea),publish-list label:has(textarea){display:block}
1420-rpc-form output,signed-form output,publish-list output,file-tools output,file-workspace output,file-workspace-root output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
1421+rpc-form output,signed-form output,publish-list output,file-tools output,file-workspace output,file-workspace-root output,private-services output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
1422 rpc-form output[data-error],file-tools output[data-error],file-workspace output[data-error]{color:var(--alert)}
1423 file-workspace-root ul{list-style:none;margin:8px 0 0;padding:0 0 0 16px;border-left:1px solid var(--line)}file-workspace-root li{padding:3px 0}
1424 file-workspace-root button{background:transparent;border:0;padding:0;color:var(--accent);font:inherit;text-align:left}
1425diff --git a/internal/webui/templates.go b/internal/webui/templates.go
1426index 1bebb9c..aa806f2 100644
1427--- a/internal/webui/templates.go
1428+++ b/internal/webui/templates.go
1429@@ -41,6 +41,9 @@ var blossomManifestsJS string
1430 //go:embed file-messages.js
1431 var fileMessagesJS string
1432
1433+//go:embed private-services.js
1434+var privateServicesJS string
1435+
1436 //go:embed blossom-upload.js
1437 var blossomUploadJS string
1438
1439@@ -125,7 +128,7 @@ func repoView(query url.Values) string {
1440 }
1441
1442 func parseTemplates() (*template.Template, error) {
1443- scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "file-workspace.js": fileWorkspaceJS}
1444+ scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}
1445 funcs := template.FuncMap{
1446 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },
1447 "script": func(name string) (template.JS, error) {
1448diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
1449index 31c0831..e3ce614 100644
1450--- a/internal/webui/webui_test.go
1451+++ b/internal/webui/webui_test.go
1452@@ -322,7 +322,7 @@ func TestConnectPageEditsConnectionsAsList(t *testing.T) {
1453 recorder := httptest.NewRecorder()
1454 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))
1455 body := recorder.Body.String()
1456- for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`} {
1457+ for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`, "<private-services", "kind 10318", "NIP-44"} {
1458 if !strings.Contains(body, want) {
1459 t.Errorf("connect page missing %q", want)
1460 }
1461diff --git a/scripts/configure-git-performance.mjs b/scripts/configure-git-performance.mjs
1462index d51e0b7..da1f314 100644
1463--- a/scripts/configure-git-performance.mjs
1464+++ b/scripts/configure-git-performance.mjs
1465@@ -3,6 +3,7 @@ import { finalizeEvent } from "nostr-tools/pure";
1466 import { getToken } from "nostr-tools/nip98";
1467
1468 const url = process.argv[2] || "http://127.0.0.1:17447";
1469+const privateRepos = process.argv.includes("--private");
1470 const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex"));
1471 async function rpc(method, params = []) {
1472 const payload = {method, params};
1473@@ -15,5 +16,6 @@ async function rpc(method, params = []) {
1474 return result.result;
1475 }
1476 const policy = await rpc("getpolicy");
1477-policy.features = {...policy.features, grasp: true, grasp02: false, grasp03: false, grasp05: false, grasp06: false};
1478+policy.features = {...policy.features, grasp: true, grasp02: false, grasp03: false, grasp05: false, grasp06: false, grasp08: privateRepos};
1479+if (privateRepos) policy.reads = "members";
1480 console.log(JSON.stringify(await rpc("setpolicy", [policy])));
1481diff --git a/scripts/conformance/grasp08.test.ts b/scripts/conformance/grasp08.test.ts
1482new file mode 100644
1483index 0000000..2b3b7a1
1484--- /dev/null
1485+++ b/scripts/conformance/grasp08.test.ts
1486@@ -0,0 +1,108 @@
1487+// GRASP-08 interoperability uses stock Git Smart HTTP with one reusable,
1488+// repository-root NIP-98 proof shared through git's extraHeader option.
1489+import { execFile } from "node:child_process";
1490+import { mkdtemp, rm, writeFile } from "node:fs/promises";
1491+import { tmpdir } from "node:os";
1492+import { promisify } from "node:util";
1493+import { describe, expect, it } from "vitest";
1494+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
1495+import { npubEncode } from "nostr-tools/nip19";
1496+import { getToken } from "nostr-tools/nip98";
1497+import { sha256 } from "@noble/hashes/sha2.js";
1498+import { hexToBytes } from "@noble/hashes/utils.js";
1499+import { Client, HTTP_URL, RELAY_URL, now } from "./helpers.ts";
1500+
1501+const exec = promisify(execFile);
1502+const KIND_REPO = 30617;
1503+const KIND_REPO_STATE = 30618;
1504+const OWNER_SK = process.env.CLAIM_SK ? hexToBytes(process.env.CLAIM_SK) : sha256(new TextEncoder().encode("tiny conformance owner"));
1505+
1506+async function git(cwd: string, ...args: string[]): Promise<string> {
1507+ const result = await exec("git", args, {
1508+ cwd,
1509+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
1510+ });
1511+ return result.stdout.trim();
1512+}
1513+
1514+function auth(sk: Uint8Array, root: string): string {
1515+ const event = finalizeEvent({ kind: 27235, created_at: now(), content: "", tags: [["u", root], ["method", "GET"]] }, sk);
1516+ return `Nostr ${Buffer.from(JSON.stringify(event)).toString("base64")}`;
1517+}
1518+
1519+async function rpc(method: string, params: unknown[] = []): Promise<any> {
1520+ const payload = { method, params };
1521+ const token = await getToken(HTTP_URL, "POST", event => finalizeEvent(event, OWNER_SK), true, payload);
1522+ const response = await fetch(HTTP_URL, {
1523+ method: "POST",
1524+ headers: { "Content-Type": "application/nostr+json+rpc", Authorization: token },
1525+ body: JSON.stringify(payload),
1526+ });
1527+ const result: any = await response.json();
1528+ if (!response.ok || result.error) throw new Error(JSON.stringify(result));
1529+ return result.result;
1530+}
1531+
1532+async function waitForRefs(url: string, authorization: string): Promise<void> {
1533+ for (let attempt = 0; attempt < 60; attempt++) {
1534+ const response = await fetch(`${url}/info/refs?service=git-upload-pack`, { headers: { authorization } });
1535+ const text = await response.text();
1536+ if (response.status === 200 && text.includes("# service=git-upload-pack")) return;
1537+ await new Promise(resolve => setTimeout(resolve, 100));
1538+ }
1539+ throw new Error("GRASP-08 repository did not become available");
1540+}
1541+
1542+describe("GRASP-08 stock Git", () => {
1543+ it("reuses one repository-root proof for clone, fetch and push", async ({ skip }) => {
1544+ if (process.env.TINY_GRASP08 !== "1") skip();
1545+ const policy = await rpc("getpolicy");
1546+ policy.features = { ...policy.features, grasp: true, grasp08: true };
1547+ policy.reads = "members";
1548+ await rpc("setpolicy", [policy]);
1549+ const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } });
1550+ const info: any = await infoResponse.json();
1551+ expect(info.supported_grasps).toContain("GRASP-08");
1552+
1553+ const root = await mkdtemp(`${tmpdir()}/tiny-grasp08-`);
1554+ const clone = await mkdtemp(`${tmpdir()}/tiny-grasp08-clone-`);
1555+ const sk = OWNER_SK;
1556+ const pubkey = getPublicKey(sk);
1557+ const npub = npubEncode(pubkey);
1558+ const identifier = `stock-${Math.random().toString(36).slice(2, 10)}`;
1559+ const repoURL = `${HTTP_URL}/${npub}/${encodeURIComponent(identifier)}.git`;
1560+ const proof = auth(sk, repoURL);
1561+ const c = await Client.connect(RELAY_URL);
1562+ try {
1563+ await c.auth(OWNER_SK, RELAY_URL);
1564+ await git(root, "init", "--initial-branch=main");
1565+ await git(root, "config", "user.email", "grasp08-conformance@example.com");
1566+ await git(root, "config", "user.name", "GRASP-08 conformance");
1567+ await writeFile(`${root}/README.md`, "private GRASP-08 repository\n");
1568+ await git(root, "add", "README.md");
1569+ await git(root, "commit", "-m", "initial");
1570+ const first = await git(root, "rev-parse", "HEAD");
1571+ const createdAt = now();
1572+ await c.publish(finalizeEvent({ kind: KIND_REPO, content: "", created_at: createdAt, tags: [["d", identifier], ["clone", repoURL], ["relays", RELAY_URL], ["maintainers", pubkey], ["private", "true"]] }, sk));
1573+ await c.publish(finalizeEvent({ kind: KIND_REPO_STATE, content: "", created_at: createdAt + 1, tags: [["d", identifier], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", first]] }, sk));
1574+ const header = `Authorization: ${proof}`;
1575+ await waitForRefs(repoURL, proof);
1576+ await git(root, "-c", `http.extraHeader=${header}`, "push", repoURL, "HEAD:refs/heads/main");
1577+ await git(clone, "-c", `http.extraHeader=${header}`, "clone", repoURL, ".");
1578+ expect(await git(clone, "rev-parse", "HEAD")).toBe(first);
1579+
1580+ await writeFile(`${root}/README.md`, "private GRASP-08 incremental repository\n");
1581+ await git(root, "add", "README.md");
1582+ await git(root, "commit", "-m", "incremental");
1583+ const second = await git(root, "rev-parse", "HEAD");
1584+ await c.publish(finalizeEvent({ kind: KIND_REPO_STATE, content: "", created_at: createdAt + 2, tags: [["d", identifier], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", second]] }, sk));
1585+ await waitForRefs(repoURL, proof);
1586+ await git(root, "-c", `http.extraHeader=${header}`, "push", repoURL, "HEAD:refs/heads/main");
1587+ await git(clone, "-c", `http.extraHeader=${header}`, "fetch", repoURL, "refs/heads/main:refs/remotes/origin/main");
1588+ expect(await git(clone, "rev-parse", "refs/remotes/origin/main")).toBe(second);
1589+ } finally {
1590+ c.close();
1591+ await Promise.all([rm(root, { recursive: true, force: true }), rm(clone, { recursive: true, force: true })]);
1592+ }
1593+ }, 120_000);
1594+});
1595diff --git a/scripts/git-performance.mjs b/scripts/git-performance.mjs
1596index 5202582..5939539 100644
1597--- a/scripts/git-performance.mjs
1598+++ b/scripts/git-performance.mjs
1599@@ -5,6 +5,7 @@ import { appendFile, mkdtemp, mkdir, readdir, rm, writeFile, rename } from "node
1600 import { tmpdir } from "node:os";
1601 import { resolve, join, basename } from "node:path";
1602 import { promisify } from "node:util";
1603+import { randomUUID } from "node:crypto";
1604 import WebSocket from "ws";
1605 import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";
1606 import { npubEncode } from "nostr-tools/nip19";
1607@@ -19,6 +20,10 @@ const outDir = resolve(option("--out", "artifacts/git-performance/run"));
1608 const baseline = option("--baseline", "").replace(/\/$/, "");
1609 const only = option("--only", "").split(",").filter(Boolean);
1610 const privateRepos = args.includes("--private");
1611+const privateRunID = randomUUID().slice(0, 8);
1612+const privateOwnerSecretHex = option("--private-owner-secret", process.env.TINY_BENCHMARK_PRIVATE_OWNER_SECRET || "fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd");
1613+if (privateRepos && !/^[0-9a-fA-F]{64}$/.test(privateOwnerSecretHex)) throw Error("--private-owner-secret must be 64 hex characters");
1614+const privateOwnerSecret = privateRepos ? Uint8Array.from(Buffer.from(privateOwnerSecretHex, "hex")) : null;
1615 const repeats = Math.max(1, Number(option("--repeats", "3")));
1616 if (!reposDir) throw Error("--repos-dir is required");
1617 const httpURL = relay.replace(/^ws/, "http").replace(/\/$/, "");
1618@@ -154,16 +159,16 @@ async function benchmarkFixture(fixture, index) {
1619 const refs = await refsFor(fixture);
1620 result.head = head;
1621 result.refs = refs.length;
1622- const sk = generateSecretKey(), pubkey = getPublicKey(sk), id = `${name.replace(/\.git$/, "")}-${index}`;
1623+ const sk = privateRepos ? privateOwnerSecret : generateSecretKey(), pubkey = getPublicKey(sk), id = `${name.replace(/\.git$/, "")}-${index}${privateRepos ? "-" + privateRunID : ""}`;
1624 const repositoryPath = `/${npubEncode(pubkey)}/${encodeURIComponent(id)}.git`;
1625 const canonicalURL = httpURL + repositoryPath;
1626 let url = canonicalURL;
1627 result.url = canonicalURL;
1628 if (privateRepos) {
1629 await control.authenticate(sk);
1630- signingProxy = await startGitSigningProxy(httpURL, sk);
1631+ signingProxy = await startGitSigningProxy(httpURL, sk, {grasp08: true});
1632 url = signingProxy.url + repositoryPath;
1633- result.transport = "local NIP-98 signing proxy; request hash spooled on client";
1634+ result.transport = "local GRASP-08 signing proxy; request body spooled on client";
1635 }
1636 const announceAt = Math.floor(Date.now()/1000);
1637 const announcement = finalizeEvent({kind: 30617, created_at: announceAt, content: "", tags: [["d", id], ["clone", canonicalURL], ["relays", relay], ["maintainers", pubkey], ...(privateRepos ? [["private", "true"]] : [])]}, sk);
1638diff --git a/scripts/git-signing-proxy.mjs b/scripts/git-signing-proxy.mjs
1639index c8b009b..7c4f7ac 100644
1640--- a/scripts/git-signing-proxy.mjs
1641+++ b/scripts/git-signing-proxy.mjs
1642@@ -17,7 +17,7 @@ import { finalizeEvent } from "nostr-tools/pure";
1643 // startGitSigningProxy forwards to target (an http or https base URL). The
1644 // signer is either a secret key (Uint8Array) or an async function that takes
1645 // an event template and returns the signed event, such as a NIP-46 bunker.
1646-export async function startGitSigningProxy(target, signer) {
1647+export async function startGitSigningProxy(target, signer, options = {}) {
1648 const sign = typeof signer === "function" ? signer : template => finalizeEvent(template, signer);
1649 const server = http.createServer(async (request, response) => {
1650 let directory;
1651@@ -30,8 +30,22 @@ export async function startGitSigningProxy(target, signer) {
1652 hash.update(chunk); size += chunk.length; done(null, chunk);
1653 }}), createWriteStream(path));
1654 const url = new URL(target + request.url);
1655- const tags = [["u", url.href], ["method", request.method], ["nonce", randomUUID()]];
1656- if (size) tags.push(["payload", hash.digest("hex")]);
1657+ let proofURL = url.href;
1658+ let proofMethod = request.method;
1659+ if (options.grasp08) {
1660+ const marker = url.pathname.lastIndexOf(".git");
1661+ if (marker < 0 || !["", "/"].includes(url.pathname.slice(marker + 4, marker + 5))) {
1662+ throw Error("GRASP08 signing requires a repository URL");
1663+ }
1664+ proofURL = new URL(url.href);
1665+ proofURL.pathname = url.pathname.slice(0, marker + 4);
1666+ proofURL.search = "";
1667+ proofURL.hash = "";
1668+ proofURL = proofURL.href;
1669+ proofMethod = "GET";
1670+ }
1671+ const tags = [["u", proofURL], ["method", proofMethod], ["nonce", randomUUID()]];
1672+ if (size && !options.grasp08) tags.push(["payload", hash.digest("hex")]);
1673 const proof = await sign({kind: 27235, created_at: Math.floor(Date.now()/1000), tags, content: ""});
1674 const headers = {...request.headers, host: url.host, "content-length": String(size),
1675 authorization: "Nostr " + Buffer.from(JSON.stringify(proof)).toString("base64")};
1676diff --git a/scripts/private-services.test.mjs b/scripts/private-services.test.mjs
1677new file mode 100644
1678index 0000000..55c80b5
1679--- /dev/null
1680+++ b/scripts/private-services.test.mjs
1681@@ -0,0 +1,77 @@
1682+import assert from "node:assert/strict";
1683+import {readFile} from "node:fs/promises";
1684+import test from "node:test";
1685+import vm from "node:vm";
1686+
1687+const source = await readFile(new URL("../internal/webui/private-services.js", import.meta.url), "utf8");
1688+class Element {}
1689+const sandbox = {
1690+ URL, TextEncoder, HTMLElement: Element,
1691+ document: {}, Response,
1692+ customElements: {define() {}},
1693+ tinySigner: {
1694+ getPublicKey: async () => "a".repeat(64),
1695+ nip44Encrypt: async (_pubkey, value) => "enc:" + value,
1696+ nip44Decrypt: async (_pubkey, value) => value.replace(/^enc:/, "")
1697+ },
1698+ nostr: {signEvent: async event => event},
1699+ NostrSigner: {verifyEvent: () => true}
1700+};
1701+sandbox.window = sandbox;
1702+vm.runInNewContext(source, sandbox);
1703+const api = sandbox.PrivateServices;
1704+
1705+test("accepts tenant paths while rejecting unsafe relay URLs", () => {
1706+ assert.equal(api.validURL("wss://Relay.Example/r/Private"), true);
1707+ assert.equal(api.canonicalURL("wss://Relay.Example/r/Private/"), "wss://relay.example/r/Private");
1708+ assert.equal(api.validURL("wss://relay.example/?token=secret"), false);
1709+ assert.equal(api.validURL("wss://user:pass@relay.example"), false);
1710+ assert.equal(api.validURL("https://relay.example"), false);
1711+});
1712+
1713+test("NIP-44 helpers round-trip and retain unrelated private entries", async () => {
1714+ const clear = JSON.stringify([["name", "work"], ["g", "wss://old.example"], ["p", "b".repeat(64)]]);
1715+ const encrypted = await api.encrypt("a".repeat(64), clear);
1716+ assert.equal(await api.decrypt("a".repeat(64), encrypted), clear);
1717+ assert.deepEqual(JSON.parse(JSON.stringify(api.mergeRows([["name", "work"], ["g", "wss://old.example"], ["p", "b".repeat(64)]], ["wss://new.example/r/tenant"]))), [["name", "work"], ["p", "b".repeat(64)], ["g", "wss://new.example/r/tenant"]]);
1718+});
1719+
1720+test("NIP-07 nip44 methods are accepted alongside bunker methods", async () => {
1721+ sandbox.tinySigner = null;
1722+ sandbox.nostr = {nip44: {
1723+ encrypt: async (_pubkey, value) => "nip07:" + value,
1724+ decrypt: async (_pubkey, value) => value.replace(/^nip07:/, "")
1725+ }};
1726+ const encrypted = await api.encrypt("a".repeat(64), "private");
1727+ assert.equal(encrypted, "nip07:private");
1728+ assert.equal(await api.decrypt("a".repeat(64), encrypted), "private");
1729+});
1730+
1731+test("kind 10318 wire has no public tags", () => {
1732+ const event = api.wireEvent("ciphertext");
1733+ assert.equal(event.kind, 10318);
1734+ assert.equal(JSON.stringify(event.tags), "[]");
1735+ assert.equal(event.content, "ciphertext");
1736+});
1737+
1738+test("reads only a verified kind 10318 event for the requested author", async () => {
1739+ const pubkey = "a".repeat(64);
1740+ const event = {kind: 10318, pubkey, tags: [], content: "ciphertext", id: "1", sig: "2"};
1741+ const requests = [];
1742+ sandbox.tiny = {signedFetch: async (_path, _method, body) => {
1743+ requests.push(JSON.parse(body));
1744+ return new Response(JSON.stringify([event]), {status: 200});
1745+ }};
1746+ sandbox.NostrSigner.verifyEvent = () => true;
1747+ assert.deepEqual(JSON.parse(JSON.stringify(await api.readList(pubkey))), event);
1748+ assert.deepEqual(JSON.parse(JSON.stringify(requests[0])), [{kinds: [10318], authors: [pubkey], limit: 1}]);
1749+ sandbox.tiny.signedFetch = async () => new Response(JSON.stringify([{...event, pubkey: "b".repeat(64)}]), {status: 200});
1750+ await assert.rejects(api.readList(pubkey), /invalid/);
1751+ sandbox.tiny.signedFetch = async () => new Response(JSON.stringify({items: []}), {status: 200});
1752+ await assert.rejects(api.readList(pubkey), /malformed/);
1753+});
1754+
1755+test("rejects a failed event signature", () => {
1756+ sandbox.NostrSigner.verifyEvent = () => false;
1757+ assert.equal(api.acceptEvent({kind: 10318, pubkey: "a".repeat(64), tags: [], content: ""}, "a".repeat(64)), false);
1758+});

About

ownerc01503e8035f
visibilitypublic
headf3b260bcec34

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs