- commit
- f3b260bcec34624519dda50138f1a0417da4adfd
- parent
- 8b51870933f832641be527f6d6d57c99f97f9c5f
- author
- Dami <dami.etoile@gmail.com>
@@ -57,6 +57,59 @@ func TestNIP98BindsSchemeQueryAndPath(t *testing.T) {
5757 }5858 }5959 60+func TestGRASP08ReusesRepositoryRootProofAcrossSmartHTTP(t *testing.T) {61+ now := time.Unix(1_700_000_000, 0)62+ validator := NewValidator(func() time.Time { return now })63+ root := "https://relay.example/npub1owner/repo.git"64+ e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}, {"payload", sha256Hex("different body")}})65+ header := token(t, e)66+ for _, request := range []string{root + "/info/refs?service=git-upload-pack", root + "/git-upload-pack"} {67+ if _, err := validator.VerifyGRASP08(header, request); err != nil {68+ t.Fatalf("verify reusable proof for %s: %v", request, err)69+ }70+ }71+}72+73+func TestGRASP08RepositoryRootHandlesGitSubstringInSlug(t *testing.T) {74+ got, ok := GRASP08RepositoryRoot("https://relay.example/npub1owner/foo.git-tools.git/info/refs?service=git-upload-pack")75+ if !ok || got != "https://relay.example/npub1owner/foo.git-tools.git" {76+ t.Fatalf("root=%q ok=%v", got, ok)77+ }78+}79+80+func TestGRASP08RejectsWrongRootMethodAndAgeWithUniformError(t *testing.T) {81+ now := time.Unix(1_700_000_000, 0)82+ validator := NewValidator(func() time.Time { return now })83+ for _, tc := range []struct {84+ name string85+ tags [][]string86+ at int6487+ url string88+ }{89+ {"wrong root", [][]string{{"u", "https://relay.example/npub1owner/other.git"}, {"method", "GET"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},90+ {"wrong method", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "POST"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"},91+ {"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"},92+ } {93+ t.Run(tc.name, func(t *testing.T) {94+ _, err := validator.VerifyGRASP08(token(t, signedEvent(t, 27235, tc.at, "", tc.tags)), tc.url)95+ if err != ErrGRASP08Unauthorized {96+ t.Fatalf("error=%v, want uniform error", err)97+ }98+ })99+ }100+}101+102+func TestGRASP08RejectsUnsupportedGitEndpoint(t *testing.T) {103+ now := time.Unix(1_700_000_000, 0)104+ validator := NewValidator(func() time.Time { return now })105+ e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}})106+ for _, path := range []string{"/objects/pack/a", "/info/refs/extra", "/"} {107+ if _, err := validator.VerifyGRASP08(token(t, e), "https://relay.example/npub1owner/repo.git"+path); err != ErrGRASP08Unauthorized {108+ t.Fatalf("endpoint %s accepted with error %v", path, err)109+ }110+ }111+}112+60113 func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) {61114 now := time.Unix(1_700_000_000, 0)62115 validator := NewValidator(func() time.Time { return now })@@ -179,3 +232,17 @@ func sha256Hex(value string) string {
179232 sum := sha256.Sum256([]byte(value))180233 return hex.EncodeToString(sum[:])181234 }235+236+func TestGRASP08RepositoryRootKeepsClientEscaping(t *testing.T) {237+ now := time.Unix(1_700_000_000, 0)238+ validator := NewValidator(func() time.Time { return now })239+ root := "https://relay.example/npub1owner/a%3Ab.git"240+ got, ok := GRASP08RepositoryRoot(root + "/info/refs?service=git-upload-pack")241+ if !ok || got != root {242+ t.Fatalf("root=%q ok=%v", got, ok)243+ }244+ e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}})245+ if _, err := validator.VerifyGRASP08(token(t, e), root+"/git-upload-pack"); err != nil {246+ t.Fatalf("verify proof for an escaped identifier: %v", err)247+ }248+}@@ -0,0 +1,71 @@
1+package auth2+3+import (4+ "errors"5+ "net/url"6+ "strings"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+)10+11+// ErrGRASP08Unauthorized is intentionally detail-free. GRASP-08 uses an12+// empty 401 challenge so a caller cannot distinguish an unknown repository13+// from an unauthorized one.14+var ErrGRASP08Unauthorized = errors.New("auth-required:")15+16+// VerifyGRASP08 verifies the reusable repository-root proof used by the17+// GRASP-08 private-service profile. The proof is a kind 27235 event naming18+// the repository root and GET; it is valid for 60 seconds and is reusable19+// across the repository's Smart HTTP requests. Unlike NIP-98, payload tags20+// are intentionally ignored and the proof is not replay-tracked.21+func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) {22+ e, err := decodeToken(header, "GRASP-08")23+ if err != nil {24+ return event.Event{}, ErrGRASP08Unauthorized25+ }26+ if err := event.Validate(e); err != nil || e.Kind != 27235 {27+ return event.Event{}, ErrGRASP08Unauthorized28+ }29+ now := v.now()30+ if e.CreatedAt < now.Unix()-60 || e.CreatedAt > now.Unix()+60 {31+ return event.Event{}, ErrGRASP08Unauthorized32+ }33+ if event.Tag(e, "method") != "GET" {34+ return event.Event{}, ErrGRASP08Unauthorized35+ }36+ root, ok := GRASP08RepositoryRoot(rawURL)37+ if !ok || sameRequestURL(event.Tag(e, "u"), root) != nil {38+ return event.Event{}, ErrGRASP08Unauthorized39+ }40+ return e, nil41+}42+43+// GRASP08RepositoryRoot returns the canonical repository URL for a Smart HTTP44+// request beneath a .git path.45+func GRASP08RepositoryRoot(raw string) (string, bool) {46+ u, err := url.Parse(raw)47+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {48+ return "", false49+ }50+ // Keep the client's percent-encoding so the root compares equal to the51+ // signed u tag, which is also kept as sent.52+ path := u.EscapedPath()53+ i := strings.LastIndex(path, ".git")54+ if i < 0 || (i+4 < len(path) && path[i+4] != '/') {55+ return "", false56+ }57+ rootPath := path[:i+4]58+ suffix := path[i+4:]59+ if suffix != "" && suffix != "/info/refs" && suffix != "/git-upload-pack" && suffix != "/git-receive-pack" {60+ return "", false61+ }62+ if strings.HasSuffix(rootPath, "/") {63+ return "", false64+ }65+ unescaped, err := url.PathUnescape(rootPath)66+ if err != nil {67+ return "", false68+ }69+ u.Path, u.RawPath, u.RawQuery, u.Fragment = unescaped, rootPath, "", ""70+ return u.String(), true71+}@@ -65,6 +65,8 @@ type ConfigStore struct {
6565 // integration point for replacing an in-memory policy and closing stale6666 // subscriptions.6767 OnApplied func(policy.Policy)68+ // ValidatePolicy runs before a config transaction is committed.69+ ValidatePolicy func(policy.Policy) error6870 }6971 7072 type ApplyOptions struct {@@ -340,6 +342,11 @@ func (s *ConfigStore) ApplyWithOptions(ctx context.Context, c Config, opts Apply
340342 if err != nil {341343 return Changes{}, err342344 }345+ if s.ValidatePolicy != nil {346+ if err := s.ValidatePolicy(ch.FinalPolicy); err != nil {347+ return Changes{}, err348+ }349+ }343350 if opts.DryRun {344351 return ch, nil345352 }@@ -111,9 +111,18 @@ func CapabilityRegistryWithServices(p policy.Policy, gitAvailable, blobsAvailabl
111111 if gitAvailable && p.Features.Grasp {112112 setCapability(capabilities, "NIP-34", Capability{ID: "NIP-34", Status: "enabled", Reason: "Git repository support"})113113 profiles := []string{"GRASP-01"}114+ if p.Features.Grasp02 {115+ profiles = append(profiles, "GRASP-02")116+ if p.Features.Grasp03 {117+ profiles = append(profiles, "GRASP-03")118+ }119+ }114120 if p.Features.Grasp06 {115121 profiles = append(profiles, "GRASP-06")116122 }123+ if privatePolicy(p) {124+ profiles = append(profiles, "GRASP-08")125+ }117126 setCapability(capabilities, "GRASP", Capability{ID: "GRASP", Status: "enabled", Reason: "configured GRASP profiles", Profiles: profiles})118127 } else if gitAvailable {119128 setCapability(capabilities, "NIP-34", Capability{ID: "NIP-34", Status: "configured", Reason: "Git support is available; GRASP policy is disabled"})@@ -72,7 +72,11 @@ func TestTenantIsolationAndDurableHTTPPublish(t *testing.T) {
7272 func signRequest(t *testing.T, r *http.Request, body string) {7373 t.Helper()7474 hash := sha256.Sum256([]byte(body))75- e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}}75+ tags := [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}76+ if i := strings.Index(r.URL.Path, ".git/"); i >= 0 {77+ tags = [][]string{{"u", r.URL.Scheme + "://" + r.URL.Host + r.URL.Path[:i+4]}, {"method", http.MethodGet}}78+ }79+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: tags}7680 if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil {7781 t.Fatal(err)7882 }@@ -33,6 +33,8 @@ func TestPrivateSignedGitStateRemainsHiddenAfterRestart(t *testing.T) {
3333 }3434 p := tenant.Policy()3535 p.Features.Grasp = true36+ p.Features.Grasp08 = true37+ p.Reads = "members"3638 if err := tenant.applyPolicy(ctx, p); err != nil {3739 t.Fatal(err)3840 }@@ -73,8 +75,8 @@ func TestPrivateSignedGitStateRemainsHiddenAfterRestart(t *testing.T) {
7375 req := httptest.NewRequest(http.MethodGet, "http://relay.test/r/main"+path, nil)7476 denied := httptest.NewRecorder()7577 app.ServeHTTP(denied, req)76- if denied.Code != http.StatusForbidden {77- t.Fatalf("unsigned private Git read status=%d", denied.Code)78+ if denied.Code != http.StatusUnauthorized || denied.Body.Len() != 0 {79+ t.Fatalf("unsigned private Git read status=%d body=%q", denied.Code, denied.Body.String())7880 }7981 req = httptest.NewRequest(http.MethodGet, "http://relay.test/r/main"+path, nil)8082 signRequest(t, req, "")@@ -98,7 +100,7 @@ func TestPrivateSignedGitStateRemainsHiddenAfterRestart(t *testing.T) {
98100 signRequest(t, tampered, "0001")99101 rejected := httptest.NewRecorder()100102 app.ServeHTTP(rejected, tampered)101- if rejected.Code != http.StatusForbidden || !strings.Contains(rejected.Body.String(), "payload hash") {102- t.Fatalf("tampered Git payload reached protocol handling: %d %q", rejected.Code, rejected.Body.String())103+ if rejected.Code != http.StatusOK {104+ t.Fatalf("GRASP-08 reusable proof rejected body variation: %d %q", rejected.Code, rejected.Body.String())103105 }104106 }@@ -0,0 +1,43 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "reflect"9+ "testing"10+)11+12+func TestPrivateNIP11MatchesRuntimeGRASPCapabilities(t *testing.T) {13+ _, tenant := testTenant(t)14+ p := tenant.Policy()15+ p.Features.Grasp = true16+ p.Features.Grasp02 = true17+ p.Features.Grasp03 = true18+ p.Features.Grasp06 = true19+ p.Features.Grasp08 = true20+ p.Reads = "members"21+ if err := tenant.applyPolicy(context.Background(), p); err != nil {22+ t.Fatal(err)23+ }24+ req := httptest.NewRequest(http.MethodGet, "http://relay.test/", nil)25+ req.Header.Set("Accept", "application/nostr+json")26+ res := httptest.NewRecorder()27+ tenant.ServeHTTP(res, req)28+ var info struct {29+ Profiles []string `json:"supported_grasps"`30+ }31+ if err := json.Unmarshal(res.Body.Bytes(), &info); err != nil {32+ t.Fatal(err)33+ }34+ want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}35+ if !reflect.DeepEqual(info.Profiles, want) {36+ t.Fatalf("NIP-11 profiles = %v", info.Profiles)37+ }38+ for _, capability := range tenant.Capabilities(nil) {39+ if capability.ID == "GRASP" && !reflect.DeepEqual(capability.Profiles, info.Profiles) {40+ t.Fatalf("runtime profiles = %v; NIP-11 = %v", capability.Profiles, info.Profiles)41+ }42+ }43+}@@ -82,21 +82,17 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8282 return8383 }8484 if privatePolicy(p) && strings.TrimSpace(r.Header.Get("Authorization")) == "" {85- http.Error(w, "auth-required: private Git hosting requires NIP-98", http.StatusUnauthorized)85+ writeGRASP08Challenge(w)8686 return8787 }8888 if privatePolicy(p) {89- proof, authErr := t.auth.VerifyNIP98(r.Header.Get("Authorization"), t.requestURL(r), r.Method, "")89+ proof, authErr := t.auth.VerifyGRASP08(r.Header.Get("Authorization"), t.requestURL(r))9090 if authErr != nil {91- http.Error(w, authErr.Error(), http.StatusUnauthorized)91+ writeGRASP08Challenge(w)9292 return9393 }9494 if accessErr := t.requirePrivateAccess(r.Context(), proof.PubKey); accessErr != nil {95- http.Error(w, accessErr.Error(), http.StatusForbidden)96- return97- }98- if spoolErr := spoolGitPayload(r, proof); spoolErr != nil {99- http.Error(w, spoolErr.Error(), http.StatusUnauthorized)95+ writeGRASP08Challenge(w)10096 return10197 }10298 r = privateGitRequest(r, proof)@@ -143,6 +139,11 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
143139 http.NotFound(w, r)144140 }145141 142+func writeGRASP08Challenge(w http.ResponseWriter) {143+ w.Header().Set("WWW-Authenticate", `Nostr method="GET"`)144+ w.WriteHeader(http.StatusUnauthorized)145+}146+146147 func (t *Tenant) webAddress(w http.ResponseWriter, r *http.Request) {147148 actor, err := t.resolveUIActor(r)148149 if err != nil {@@ -331,12 +332,9 @@ func (t *Tenant) information(w http.ResponseWriter, r *http.Request) {
331332 if t.records != nil {332333 info["self"] = t.records.PublicKey()333334 }334- if p.Features.Grasp && t.git != nil && !privatePolicy(p) {335+ if p.Features.Grasp && t.git != nil {335336 info["supported_grasps"] = t.git.SupportedGRASPs()336337 }337- if privatePolicy(p) && t.git != nil {338- info["supported_grasps"] = []string{"GRASP-01", "GRASP-08"}339- }340338 if p.Features.Sites.Enabled && !privatePolicy(p) {341339 base, _ := url.Parse(t.publicURL)342340 domain := t.siteDomain()@@ -10,11 +10,11 @@ import (
1010 )1111 1212 type privateGitProofKey struct{}13-type privateGitSpoolKey struct{}13+type privateGitPayloadCheckedKey struct{}1414 1515 func privateGitRequest(r *http.Request, proof event.Event) *http.Request {1616 ctx := context.WithValue(r.Context(), privateGitProofKey{}, proof)17- ctx = context.WithValue(ctx, privateGitSpoolKey{}, true)17+ ctx = context.WithValue(ctx, privateGitPayloadCheckedKey{}, struct{}{})1818 return r.WithContext(ctx)1919 }2020 @@ -137,7 +137,7 @@ func TestPrivateGitRejectsSignedOutsiderBeforeRepositoryLookup(t *testing.T) {
137137 signRequestWithSecret(t, outsider, "", strings.Repeat("3", 64))138138 denied := httptest.NewRecorder()139139 tenant.ServeHTTP(denied, outsider)140- if denied.Code != http.StatusForbidden {140+ if denied.Code != http.StatusUnauthorized || denied.Body.Len() != 0 || denied.Header().Get("WWW-Authenticate") != `Nostr method="GET"` {141141 t.Fatalf("signed outsider reached private Git lookup: %d %s", denied.Code, denied.Body.String())142142 }143143 owner := httptest.NewRequest(http.MethodGet, "http://relay.test"+path, nil)@@ -152,7 +152,11 @@ func TestPrivateGitRejectsSignedOutsiderBeforeRepositoryLookup(t *testing.T) {
152152 func signRequestWithSecret(t *testing.T, r *http.Request, body, secret string) {153153 t.Helper()154154 hash := sha256.Sum256([]byte(body))155- e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}}155+ tags := [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}156+ if i := strings.Index(r.URL.Path, ".git/"); i >= 0 {157+ tags = [][]string{{"u", r.URL.Scheme + "://" + r.URL.Host + r.URL.Path[:i+4]}, {"method", http.MethodGet}}158+ }159+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Content: "", Tags: tags}156160 if err := event.Sign(&e, secret); err != nil {157161 t.Fatal(err)158162 }@@ -10,16 +10,24 @@ import (
1010 "net/http"1111 "net/url"1212 "strings"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/auth"1315 )1416 1517 func (t *Tenant) privateHTTPAuth(ctx context.Context, method, rawURL, payloadHash string) (string, error) {18+ _ = method19+ _ = payloadHash1620 if t == nil || t.records == nil || !t.PrivateServiceEnabled() {1721 return "", fmt.Errorf("private peer: relay identity unavailable")1822 }1923 if !privatePeerMatch(rawURL, t.Policy().PrivatePeers) {2024 return "", fmt.Errorf("private peer: target is no longer configured")2125 }22- proof, err := t.records.SignNIP98(ctx, method, rawURL, payloadHash, 0)26+ root, ok := auth.GRASP08RepositoryRoot(rawURL)27+ if !ok {28+ return "", fmt.Errorf("private peer: target is not a Git repository URL")29+ }30+ proof, err := t.records.SignNIP98(ctx, http.MethodGet, root, "", 0)2331 if err != nil {2432 return "", err2533 }@@ -124,13 +124,20 @@ func TestPrivatePeerGitAndMetadataSyncAcrossTenants(t *testing.T) {
124124 publicPolicy.Features.Grasp08 = false125125 publicPolicy.Reads = "open"126126 publicPolicy.PrivatePeers = []string{guardPeer.URL}127- if err := source.applyPolicy(ctx, publicPolicy); err != nil {127+ if err := source.applyPolicy(ctx, publicPolicy); err == nil {128+ t.Fatal("private tenant policy was made public while private repository metadata existed")129+ }130+ _, publicTenant := testTenant(t)131+ publicPolicy = publicTenant.Policy()132+ publicPolicy.Features.Grasp = true133+ publicPolicy.PrivatePeers = []string{guardPeer.URL}134+ if err := publicTenant.applyPolicy(ctx, publicPolicy); err != nil {128135 t.Fatal(err)129136 }130137 publicCheckRepo := repo131138 publicCheckRepo.Identifier = "public-profile-peer"132139 publicCheckRepo.Clone = []string{guardPeer.URL + "/private.git"}133- if err := source.gitSync(ctx, publicCheckRepo); err == nil {140+ if err := publicTenant.gitSync(ctx, publicCheckRepo); err == nil {134141 t.Fatal("public profile unexpectedly fetched from a private peer")135142 }136143 if publicAuth != "" {@@ -0,0 +1,49 @@
1+package daemon2+3+import (4+ "context"5+ "errors"6+ "fmt"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+ "github.com/FelineStateMachine/tinyrelay/internal/policy"10+ "github.com/FelineStateMachine/tinyrelay/internal/storage"11+)12+13+// validatePrivateRepositoryPlacement prevents a private GRASP repository from14+// being admitted to an open tenant. State events inherit the marker from their15+// stored announcement, including when the announcement predates this guard.16+func (t *Tenant) validatePrivateRepositoryPlacement(ctx context.Context, e event.Event) error {17+ if e.Kind != event.KIND_REPO && e.Kind != event.KIND_REPO_STATE {18+ return nil19+ }20+ private, err := privateRepositoryStored(ctx, t.store, e)21+ if err != nil {22+ return fmt.Errorf("check private repository placement: %w", err)23+ }24+ if !private {25+ return nil26+ }27+ p := t.Policy()28+ if !p.Features.Grasp08 || p.Reads != "members" {29+ return errors.New("blocked: private repositories require a GRASP-08 private tenant")30+ }31+ return nil32+}33+34+func hasLegacyPrivateRepository(ctx context.Context, store *storage.Store) (bool, error) {35+ if store == nil {36+ return false, errors.New("private repository scan: missing store")37+ }38+ return store.HasPrivateRepositories(ctx)39+}40+41+func privateRepositoryStored(ctx context.Context, store *storage.Store, e event.Event) (bool, error) {42+ if policy.IsPrivateRepository(e) {43+ return true, nil44+ }45+ if store == nil || e.Kind != event.KIND_REPO_STATE {46+ return false, nil47+ }48+ return store.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))49+}@@ -0,0 +1,139 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/configport"11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+ "github.com/FelineStateMachine/tinyrelay/internal/relay"14+ "github.com/FelineStateMachine/tinyrelay/internal/replication"15+ "github.com/FelineStateMachine/tinyrelay/internal/storage"16+)17+18+func TestPrivateRepositoryRejectedOnOpenTenantPublishAndImport(t *testing.T) {19+ _, tenant := testTenant(t)20+ p := tenant.Policy()21+ p.Features.Grasp = true22+ p.Reads = "open"23+ if err := tenant.applyPolicy(context.Background(), p); err != nil {24+ t.Fatal(err)25+ }26+ secret := strings.Repeat("4", 64)27+ owner, err := event.PublicKey(secret)28+ if err != nil {29+ t.Fatal(err)30+ }31+ e := event.Event{Kind: event.KIND_REPO, PubKey: owner, CreatedAt: 1, Tags: [][]string{{"d", "private"}, {"private", "true"}, {"clone", "https://relay.example/private.git"}}}32+ if err := event.Sign(&e, secret); err != nil {33+ t.Fatal(err)34+ }35+ if _, err := tenant.router.Publish(context.Background(), e, relay.Session{PubKeys: []string{owner}}); err == nil || !strings.Contains(err.Error(), "GRASP-08") {36+ t.Fatalf("open publish error = %v", err)37+ }38+ if err := tenant.ingest(context.Background(), e, replication.OriginImport); err == nil || !strings.Contains(err.Error(), "GRASP-08") {39+ t.Fatalf("open import error = %v", err)40+ }41+}42+43+func TestPrivateRepositoryPolicyCannotBeReopened(t *testing.T) {44+ owner, err := event.PublicKey(strings.Repeat("5", 63) + "1")45+ if err != nil {46+ t.Fatal(err)47+ }48+ _, tenant, _ := privatePeerTenant(t, "private-policy", owner)49+ e := event.Event{Kind: event.KIND_REPO, PubKey: owner, CreatedAt: 1, Tags: [][]string{{"d", "private"}, {"private", "true"}}}50+ if err := event.Sign(&e, strings.Repeat("5", 63)+"1"); err != nil {51+ t.Fatal(err)52+ }53+ if _, err := tenant.store.Save(context.Background(), e, storage.SaveOptions{Now: 1}); err != nil {54+ t.Fatal(err)55+ }56+ p := tenant.Policy()57+ p.Features.Grasp08 = false58+ p.Reads = "open"59+ if err := tenant.applyPolicy(context.Background(), p); err == nil {60+ t.Fatal("private policy transition was accepted")61+ }62+ patch := configport.Config{Format: configport.Format, Policy: map[string]json.RawMessage{63+ "features": json.RawMessage(`{"grasp08":false}`), "reads": json.RawMessage(`"open"`),64+ }}65+ if _, err := tenant.config.Apply(context.Background(), patch, false); err == nil {66+ t.Fatal("config import reopened a private tenant")67+ }68+ var stored policy.Policy69+ if err := tenant.store.GetSetting(context.Background(), "policy", &stored); err != nil {70+ t.Fatal(err)71+ }72+ if !tenant.PrivateServiceEnabled() || !stored.Features.Grasp08 || stored.Reads != "members" {73+ t.Fatal("rejected import changed the effective or durable private policy")74+ }75+}76+77+func TestLegacyPrivateRepositoryForcesBoundaryAfterRestart(t *testing.T) {78+ ctx := context.Background()79+ dir := t.TempDir()80+ app, err := New(ctx, Config{DataDir: dir, DefaultTenant: "main"})81+ if err != nil {82+ t.Fatal(err)83+ }84+ secret := strings.Repeat("6", 63) + "1"85+ owner, err := event.PublicKey(secret)86+ if err != nil {87+ t.Fatal(err)88+ }89+ meta, err := app.Create(ctx, CreateOptions{Name: "main", Owner: owner, Template: "default"})90+ if err != nil {91+ t.Fatal(err)92+ }93+ tenant, err := app.tenant(ctx, meta, "http://relay.test")94+ if err != nil {95+ t.Fatal(err)96+ }97+ now := time.Now().Unix()98+ items := []event.Event{99+ {Kind: event.KIND_REPO, PubKey: owner, CreatedAt: now, Tags: [][]string{{"d", "legacy"}, {"private", "true"}}},100+ {Kind: event.KIND_REPO_STATE, PubKey: owner, CreatedAt: now + 1, Tags: [][]string{{"d", "legacy"}}},101+ {Kind: 1621, PubKey: owner, CreatedAt: now + 2, Tags: [][]string{{"e", "legacy"}}},102+ }103+ for i := range items {104+ if err := event.Sign(&items[i], secret); err != nil {105+ t.Fatal(err)106+ }107+ if _, err := tenant.store.Save(ctx, items[i], storage.SaveOptions{Now: now}); err != nil {108+ t.Fatal(err)109+ }110+ }111+ if err := app.Close(ctx); err != nil {112+ t.Fatal(err)113+ }114+ app, err = New(ctx, Config{DataDir: dir, DefaultTenant: "main"})115+ if err != nil {116+ t.Fatal(err)117+ }118+ if err := app.Start(ctx, "http://relay.test"); err != nil {119+ t.Fatal(err)120+ }121+ t.Cleanup(func() { _ = app.Close(ctx) })122+ reloaded := app.tenants[meta.ID]123+ if reloaded == nil || !reloaded.PrivateServiceEnabled() || reloaded.Policy().Reads != "members" {124+ t.Fatal("legacy private data did not force members boundary")125+ }126+ if _, err := reloaded.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_REPO, event.KIND_REPO_STATE, 1621}}}, relay.Session{}); err == nil {127+ t.Fatal("anonymous query reached legacy private metadata")128+ }129+ if _, err := reloaded.Count(ctx, []event.Filter{{Kinds: []int{event.KIND_REPO}}}, relay.Session{}); err == nil {130+ t.Fatal("anonymous count reached legacy private metadata")131+ }132+ var durable policy.Policy133+ if err := reloaded.store.GetSetting(ctx, "policy", &durable); err != nil {134+ t.Fatal(err)135+ }136+ if !durable.Features.Grasp08 || durable.Reads != "members" {137+ t.Fatal("legacy boundary must remain private after its announcement is removed")138+ }139+}@@ -100,7 +100,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
100100 if err != nil {101101 return err102102 }103- t.config = configport.New(configport.ConfigStore{Store: t.store, Community: t.community, Policy: t.Policy, OnApplied: func(p policy.Policy) { t.replacePolicy(p) }})103+ 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) }})104104 transport := &replication.NostrTransport{Dialer: replication.WebsocketDialer{AllowPrivate: t.app.cfg.AllowPrivateRelays, MaxMessageBytes: t.app.cfg.MaxMessageBytes}}105105 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 {106106 p := t.Policy()@@ -378,7 +378,7 @@ func (t *Tenant) authorizeGit(ctx context.Context, r *http.Request, repo gitrela
378378 return errors.New("restricted: repository authorization required")379379 }380380 }381- if _, spooled := r.Context().Value(privateGitSpoolKey{}).(bool); spooled {381+ if _, checked := r.Context().Value(privateGitPayloadCheckedKey{}).(struct{}); checked {382382 return nil383383 }384384 return spoolGitPayload(r, e)@@ -605,6 +605,9 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
605605 if err := t.gate.Import(ctx, e, time.Now().Unix()); err != nil {606606 return err607607 }608+ if err := t.validatePrivateRepositoryPlacement(ctx, e); err != nil {609+ return err610+ }608611 if t.sites != nil {609612 if err := sites.ValidateManifest(e); err != nil {610613 return err@@ -5,6 +5,7 @@ import (
55 "database/sql"66 "encoding/json"77 "errors"8+ "fmt"89 "net/http"910 "sort"1011 "strings"@@ -66,8 +67,22 @@ type Tenant struct {
6667 }6768 6869 func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {69- 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)}70- var err error70+ p := cfg.policy71+ legacyPrivate, err := hasLegacyPrivateRepository(ctx, cfg.store)72+ if err != nil {73+ return nil, err74+ }75+ if legacyPrivate && (!p.Features.Grasp08 || p.Reads != "members") {76+ // Keep the private boundary durable even if an announcement is later77+ // removed while its repository or collaboration data remains.78+ p.Features.Grasp = true79+ p.Features.Grasp08 = true80+ p.Reads = "members"81+ if err := cfg.store.WithTx(ctx, func(tx *sql.Tx) error { return storage.PutSetting(ctx, tx, "policy", p) }); err != nil {82+ return nil, fmt.Errorf("preserve legacy repository privacy: %w", err)83+ }84+ }85+ 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)}7186 t.community, err = community.New(ctx, t.store, t.policy.Owner)7287 if err != nil {7388 return nil, err@@ -149,6 +164,9 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
149164 if err := t.gate.Write(ctx, e, s, now); err != nil {150165 return "", err151166 }167+ if err := t.validatePrivateRepositoryPlacement(ctx, e); err != nil {168+ return "", err169+ }152170 if err := t.validatePushRegistration(ctx, e, s); err != nil {153171 return "", err154172 }@@ -567,8 +585,19 @@ func (t *Tenant) applyPolicy(ctx context.Context, next policy.Policy) error {
567585 return t.persistPolicy(ctx, next)568586 }569587 588+func (t *Tenant) validatePolicyTransition(next policy.Policy) error {589+ previous := t.Policy()590+ if previous.Features.Grasp08 && previous.Reads == "members" && (!next.Features.Grasp08 || next.Reads != "members") {591+ return errors.New("blocked: opening a private GRASP-08 tenant requires an explicit data migration")592+ }593+ return nil594+}595+570596 func (t *Tenant) persistPolicy(ctx context.Context, next policy.Policy) error {571597 previous := t.Policy()598+ if err := t.validatePolicyTransition(next); err != nil {599+ return err600+ }572601 err := t.store.WithTx(ctx, func(tx *sql.Tx) error {573602 if next.Owner != previous.Owner {574603 if err := t.community.ApplyOwnerTx(ctx, tx, previous.Owner, next.Owner); err != nil {@@ -465,6 +465,10 @@ func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *ev
465465 return false466466 }467467 }468+ private, err := privateRepositoryEvent(ctx, g.cfg.Store, e)469+ if err != nil || private && (!p.Features.Grasp08 || p.Reads != "members") {470+ return false471+ }468472 if g.cfg.Community != nil {469473 banned, err := g.cfg.Community.IsEventBanned(ctx, e.ID)470474 if err != nil || banned {@@ -507,6 +511,19 @@ func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *ev
507511 return true508512 }509513 514+// privateRepositoryEvent also protects legacy state events. Their privacy is515+// inherited from the corresponding replaceable announcement, so checking only516+// the state event's own tags would allow metadata to escape after a restart.517+func privateRepositoryEvent(ctx context.Context, store *storage.Store, e event.Event) (bool, error) {518+ if policy.IsPrivateRepository(e) {519+ return true, nil520+ }521+ if store == nil || e.Kind != event.KIND_REPO_STATE {522+ return false, nil523+ }524+ return store.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))525+}526+510527 func canSeeSignerMessage(e event.Event, s relay.Session, f *event.Filter) bool {511528 if contains(s.PubKeys, e.PubKey) {512529 return true@@ -0,0 +1,34 @@
1+package gitrelay2+3+import (4+ "context"5+ "reflect"6+ "testing"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/policy"9+)10+11+func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {12+ p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true}}13+ g := &GitRelay{policy: func() policy.Policy { return p }}14+ if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {15+ t.Fatalf("without event transport: %v", got)16+ }17+ g.eventSync = func(context.Context, Repository) error { return nil }18+ if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06"}) {19+ t.Fatalf("with event transport: %v", got)20+ }21+ p.Features.Grasp02 = false22+ if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {23+ t.Fatalf("without base sync: %v", got)24+ }25+}26+27+func TestCapabilitiesPreservePrivateProfiles(t *testing.T) {28+ p := policy.Policy{Reads: "members", Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true, Grasp08: true}}29+ g := &GitRelay{policy: func() policy.Policy { return p }, eventSync: func(context.Context, Repository) error { return nil }}30+ want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}31+ if got := g.SupportedGRASPs(); !reflect.DeepEqual(got, want) {32+ t.Fatalf("private profiles: %v", got)33+ }34+}@@ -314,10 +314,12 @@ func repoFingerprint(r Repository, profile policy.Policy) string {
314314 }315315 sort.Strings(refs)316316 profileJSON, _ := json.Marshal(struct {317+ Grasp bool `json:"grasp"`318+ Reads string `json:"reads"`317319 Grasp02 bool `json:"grasp02"`318320 Grasp03 bool `json:"grasp03"`319321 Peers []string `json:"peers"`320- }{profile.Features.Grasp02, profile.Features.Grasp03, profile.PrivatePeers})322+ }{profile.Features.Grasp, profile.Reads, profile.Features.Grasp02, profile.Features.Grasp03, profile.PrivatePeers})321323 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)}322324 sum := sha256.Sum256([]byte(strings.Join(parts, "\x01")))323325 return hex.EncodeToString(sum[:])@@ -917,12 +919,21 @@ func (g *GitRelay) validateRefs(refs map[string]string) error {
917919 }918920 919921 // Capabilities returns only protocol surfaces that this instance can serve.920-// GRASP-02 is an operator-triggered fetch API; GRASP-06 is explicitly opt-in.921922 func (g *GitRelay) Capabilities() []string {923+ p := g.policy()922924 c := []string{"GRASP-01"}923- if g.grasp06 || g.policy().Features.Grasp06 {925+ if p.Features.Grasp02 && g.eventSync != nil {926+ c = append(c, "GRASP-02")927+ if p.Features.Grasp03 {928+ c = append(c, "GRASP-03")929+ }930+ }931+ if g.grasp06 || p.Features.Grasp06 {924932 c = append(c, "GRASP-06")925933 }934+ if p.Features.Grasp && p.Features.Grasp08 && p.Reads == "members" {935+ c = append(c, "GRASP-08")936+ }926937 return c927938 }928939 @@ -1232,6 +1243,12 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12321243 }12331244 }12341245 for _, raw := range sources {1246+ // Private object IDs must never be requested from a public source.1247+ private := r.Private || (g.policy != nil && g.policy().Reads == "members")1248+ if private && !g.privatePeer(raw) {1249+ lastErr = errors.New("blocked: private Git source is not a configured peer")1250+ continue1251+ }12351252 source, ip, err := g.resolvedSource(ctx, raw)12361253 if err != nil {12371254 continue@@ -1241,7 +1258,10 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12411258 if err := probePrivatePeer(ctx, peerBase, g.allowPrivate); err != nil {12421259 continue12431260 }1244- proxy, err = newPrivateProxy(ctx, source, g.httpAuth, g.allowPrivate)1261+ g.mu.RLock()1262+ signer := g.httpAuth1263+ g.mu.RUnlock()1264+ proxy, err = newPrivateProxy(ctx, source, signer, g.allowPrivate)12451265 if err != nil {12461266 continue12471267 }@@ -1259,7 +1279,16 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12591279 fetchSource = proxy.URL()12601280 resolve = nil12611281 }1262- 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)}1282+ fetchRef := ref1283+ if strings.HasPrefix(ref, "refs/nostr/") {1284+ // NIP-34 clone URLs may point to ordinary Git hosts. The1285+ // signed commit is the source; refs/nostr is our local name.1286+ if !isObjectID(oid) {1287+ return errors.New("invalid: PR commit ID")1288+ }1289+ fetchRef = oid1290+ }1291+ 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)}12631292 // Keep the resolve option before the subcommand; Git accepts config12641293 // options only before the command name.12651294 if len(resolve) > 0 {@@ -14,11 +14,13 @@ import (
1414 "net/url"1515 "os"1616 "strings"17+18+ "github.com/FelineStateMachine/tinyrelay/internal/auth"1719 )1820 19-// HTTPAuthSigner signs one complete HTTP request. The payload hash is empty20-// for requests without a body. It is deliberately a callback so GitRelay21-// never owns or handles a tenant's private key.21+// HTTPAuthSigner returns the authorization header for a private peer request.22+// GRASP-08 callers receive the repository root, GET and an empty payload hash;23+// it is deliberately a callback so GitRelay never owns private keys.2224 type HTTPAuthSigner func(context.Context, string, string, string) (string, error)2325 2426 // ConfigurePrivatePeers refreshes operator-controlled private transport after@@ -103,7 +105,7 @@ func (p *privateProxy) serve(w http.ResponseWriter, r *http.Request) {
103105 }104106 u.Path = strings.TrimSuffix(p.base.Path, "/") + "/" + strings.TrimPrefix(suffix, "/")105107 u.RawQuery = r.URL.RawQuery106- file, hash, size, err := spoolRequest(r.Body)108+ file, _, size, err := spoolRequest(r.Body)107109 if err != nil {108110 http.Error(w, err.Error(), http.StatusBadGateway)109111 return@@ -111,11 +113,14 @@ func (p *privateProxy) serve(w http.ResponseWriter, r *http.Request) {
111113 if file != nil {112114 defer func() { _ = os.Remove(file.Name()); _ = file.Close() }()113115 }114- payload := ""115- if size > 0 {116- payload = hex.EncodeToString(hash[:])116+ signURL, ok := auth.GRASP08RepositoryRoot(u.String())117+ if !ok {118+ http.Error(w, "private peer: invalid Git repository URL", http.StatusBadGateway)119+ return117120 }118- authorization, err := p.sign(r.Context(), r.Method, u.String(), payload)121+ // GRASP-08 uses one reusable GET proof for the repository root. The122+ // concrete Smart HTTP path and request body are deliberately out of scope.123+ authorization, err := p.sign(r.Context(), http.MethodGet, signURL, "")119124 if err != nil {120125 http.Error(w, err.Error(), http.StatusBadGateway)121126 return@@ -213,7 +218,10 @@ func (g *GitRelay) privatePeerBase(source string) string {
213218 if err != nil {214219 return ""215220 }216- for _, configured := range g.privatePeers {221+ g.mu.RLock()222+ peers := g.privatePeers223+ g.mu.RUnlock()224+ for _, configured := range peers {217225 peer, err := url.Parse(strings.TrimRight(strings.TrimSpace(configured), "/"))218226 if err != nil || normalizePeerScheme(peer.Scheme) != normalizePeerScheme(u.Scheme) || peer.Host != u.Host {219227 continue@@ -20,8 +20,8 @@ func TestPrivateProxySignsAndStreamsGitRequest(t *testing.T) {
2020 _, _ = w.Write([]byte("pack-response"))2121 }))2222 t.Cleanup(remote.Close)23- proxy, err := newPrivateProxy(context.Background(), remote.URL+"/r/private", func(_ context.Context, method, target, payload string) (string, error) {24- if method != http.MethodPost || !strings.HasSuffix(target, "/r/private/info/refs") {23+ proxy, err := newPrivateProxy(context.Background(), remote.URL+"/r/owner/repo.git", func(_ context.Context, method, target, payload string) (string, error) {24+ if method != http.MethodGet || target != remote.URL+"/r/owner/repo.git" || payload != "" {2525 return "", &testError{"unexpected signed request"}2626 }2727 return "Nostr signed:" + payload, nil@@ -20,12 +20,30 @@ func CanRead(p Policy, e event.Event, a Access) bool {
2020 if !readRule(p, a) {2121 return false2222 }23+ if IsPrivateRepository(e) && (!p.Features.Grasp08 || p.Reads != "members") {24+ return false25+ }2326 if privateKind(e.Kind) {2427 return addressed(e, a)2528 }2629 return true2730 }2831 32+// IsPrivateRepository identifies a GRASP repository announcement carrying the33+// private=true marker. State events are associated with their announcement by34+// owner and identifier at the storage boundary.35+func IsPrivateRepository(e event.Event) bool {36+ if e.Kind != 30617 {37+ return false38+ }39+ for _, tag := range e.Tags {40+ if len(tag) >= 2 && tag[0] == "private" && strings.EqualFold(tag[1], "true") {41+ return true42+ }43+ }44+ return false45+}46+2947 func CanWrite(p Policy, e event.Event, a Access) bool {3048 if containsInt(p.BlockedKinds, e.Kind) {3149 return false@@ -71,6 +71,21 @@ func TestGRASP08RequiresBaseGRASP(t *testing.T) {
7171 }7272 }7373 74+func TestPrivateRepositoryRequiresGRASP08PrivateTenant(t *testing.T) {75+ e := event.Event{Kind: 30617, Tags: [][]string{{"private", "true"}}}76+ if CanRead(Policy{Reads: "open"}, e, Access{}) {77+ t.Fatal("private repository was readable on an open tenant")78+ }79+ p := Policy{Reads: "members"}80+ if !CanRead(p, event.Event{Kind: 1}, Access{Member: true}) {81+ t.Fatal("ordinary event became unreadable on a member tenant")82+ }83+ p.Features.Grasp08 = true84+ if !CanRead(p, e, Access{Member: true}) {85+ t.Fatal("private repository was unreadable on a GRASP-08 member tenant")86+ }87+}88+7489 func TestGRASPHistoryExtensionsRequireGRASP02(t *testing.T) {7590 cases := []struct {7691 name string@@ -6,6 +6,7 @@ import (
66 "fmt"77 88 "github.com/FelineStateMachine/tinyrelay/internal/event"9+ "github.com/FelineStateMachine/tinyrelay/internal/policy"910 "github.com/FelineStateMachine/tinyrelay/internal/storage"1011 )1112 @@ -29,7 +30,11 @@ func deliver(ctx context.Context, store *storage.Store, transport DeliveryTransp
2930 return nil3031 }3132 e := result.Events[0]32- if privateKind(e.Kind) || hasProtectedTag(e) {33+ private, privateErr := privateRepository(ctx, store, e)34+ if privateErr != nil {35+ return fmt.Errorf("replication: check repository privacy: %w", privateErr)36+ }37+ if privateKind(e.Kind) || hasProtectedTag(e) || private {3338 return nil3439 }3540 if e.Kind != 10002 {@@ -44,6 +49,16 @@ func deliver(ctx context.Context, store *storage.Store, transport DeliveryTransp
4449 return sendAccepted(ctx, transport, intent.Target, e)4550 }4651 52+func privateRepository(ctx context.Context, store *storage.Store, e event.Event) (bool, error) {53+ if policy.IsPrivateRepository(e) {54+ return true, nil55+ }56+ if store == nil || e.Kind != event.KIND_REPO_STATE {57+ return false, nil58+ }59+ return store.IsPrivateRepository(ctx, e.PubKey, event.Tag(e, "d"))60+}61+4762 func authorRelayList(ctx context.Context, store *storage.Store, e event.Event) (*event.Event, error) {4863 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})4964 if err != nil {@@ -0,0 +1,70 @@
1+package replication2+3+import (4+ "context"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+ "github.com/FelineStateMachine/tinyrelay/internal/storage"10+)11+12+func TestQueuedPrivateRepositoryMetadataIsNotDelivered(t *testing.T) {13+ store := openReplicationStore(t)14+ ctx := context.Background()15+ transport := &recordingTransport{}16+ handler := NewDeliveryHandler(store, transport)17+ for _, kind := range []int{event.KIND_REPO, event.KIND_REPO_STATE} {18+ e := signedEvent(t, 1)19+ e.Kind = kind20+ e.Tags = [][]string{{"d", "private"}}21+ if kind == event.KIND_REPO {22+ e.Tags = append(e.Tags, []string{"private", "true"})23+ }24+ if err := event.Sign(&e, strings.Repeat("1", 64)); err != nil {25+ t.Fatal(err)26+ }27+ if _, err := store.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt}); err != nil {28+ t.Fatal(err)29+ }30+ if err := handler(ctx, storage.Intent{Kind: "delivery", EventID: e.ID, Target: "wss://public.example"}); err != nil {31+ t.Fatal(err)32+ }33+ }34+ if len(transport.events) != 0 {35+ t.Fatal("private metadata escaped through a queued public delivery")36+ }37+}38+39+func TestPrivateScopeLookupFailureRetainsDeliveryForRetry(t *testing.T) {40+ store := openReplicationStore(t)41+ ctx := context.Background()42+ e := signedEvent(t, 1)43+ e.Kind = event.KIND_REPO_STATE44+ e.Tags = [][]string{{"d", "private"}}45+ if err := event.Sign(&e, strings.Repeat("1", 64)); err != nil {46+ t.Fatal(err)47+ }48+ if _, err := store.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt}); err != nil {49+ t.Fatal(err)50+ }51+ announcement := e52+ announcement.Kind = event.KIND_REPO53+ if err := event.Sign(&announcement, strings.Repeat("1", 64)); err != nil {54+ t.Fatal(err)55+ }56+ if _, err := store.Save(ctx, announcement, storage.SaveOptions{Now: announcement.CreatedAt}); err != nil {57+ t.Fatal(err)58+ }59+ if _, err := store.DB().ExecContext(ctx, "UPDATE events SET raw='invalid JSON' WHERE id=?", announcement.ID); err != nil {60+ t.Fatal(err)61+ }62+ transport := &recordingTransport{}63+ err := NewDeliveryHandler(store, transport)(ctx, storage.Intent{Kind: "delivery", EventID: e.ID, Target: "wss://public.example"})64+ if err == nil {65+ t.Fatal("privacy lookup failure was acknowledged instead of retained for retry")66+ }67+ if len(transport.events) != 0 {68+ t.Fatal("delivery proceeded after a privacy lookup failure")69+ }70+}@@ -0,0 +1,36 @@
1+package storage2+3+import (4+ "context"5+ "fmt"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/event"8+)9+10+// HasPrivateRepositories checks the stored announcements, including legacy11+// events. Multiword tags such as private are not in the NIP-01 tag index.12+func (s *Store) HasPrivateRepositories(ctx context.Context) (bool, error) {13+ return s.hasPrivateRepository(ctx, "", "")14+}15+16+// IsPrivateRepository resolves a state event's privacy from its announcement.17+func (s *Store) IsPrivateRepository(ctx context.Context, pubkey, identifier string) (bool, error) {18+ return s.hasPrivateRepository(ctx, pubkey, identifier)19+}20+21+func (s *Store) hasPrivateRepository(ctx context.Context, pubkey, identifier string) (bool, error) {22+ query := `SELECT EXISTS(SELECT 1 FROM events e WHERE e.kind=?`23+ args := []any{event.KIND_REPO}24+ if pubkey != "" {25+ query += ` AND e.pubkey=? AND e.d=?`26+ args = append(args, pubkey, identifier)27+ }28+ query += ` AND EXISTS(SELECT 1 FROM json_each(e.raw, '$.tags') tag29+ WHERE json_extract(tag.value, '$[0]')='private'30+ AND lower(json_extract(tag.value, '$[1]'))='true'))`31+ var found bool32+ if err := s.DB().QueryRowContext(ctx, query, args...).Scan(&found); err != nil {33+ return false, fmt.Errorf("check stored repository privacy: %w", err)34+ }35+ return found, nil36+}@@ -23,7 +23,8 @@
2323 // <file-tools [hash="…"] [type="…"]>2424 // Copies Blossom links, encrypts and uploads a file, decrypts a share2525 // link fragment and sends a random-key file as a NIP-17 message.26-// <file-workspace> and <file-workspace-root> live in file-workspace.js.26+// <file-workspace> and <file-workspace-root> live in file-workspace.js;27+// <private-services> lives in private-services.js.2728 (() => {2829 "use strict";2930 @@ -112,6 +112,13 @@
112112 </details>113113 <h3>Publish signed relay records</h3>114114 <p>These actions open the configured signer and publish owner-authored Nostr records.</p>115+<private-services>116+<h3>Private Git relay list</h3>117+<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>118+<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>119+<noscript><p>Connect a signer and enable JavaScript to encrypt your private service list.</p></noscript>120+<output role="status"></output>121+</private-services>115122 <publish-list kind="10317" tag="g" scheme="ws">116123 <label>GRASP relay URLs, one per line <textarea name="lines" placeholder="wss://relay.example wss://backup.example"></textarea></label>117124 <button>Publish GRASP list</button>@@ -62,7 +62,13 @@
6262 <script>{{script "blossom-upload.js"}}</script>6363 <script>{{script "file-messages.js"}}</script>6464 <script>{{script "file-workspace.js"}}</script>65+<script>{{script "blossom-encryption.js"}}</script>66+<script>{{script "blossom-manifests.js"}}</script>67+<script>{{script "blossom-upload.js"}}</script>68+<script>{{script "file-messages.js"}}</script>69+<script>{{script "file-workspace.js"}}</script>6570 <script>{{script "components.js"}}</script>71+<script>{{script "private-services.js"}}</script>6672 </body>6773 </html>6874 {{end}}@@ -0,0 +1,135 @@
1+// PrivateServices edits the encrypted GRASP-08 kind 10318 list. The relay2+// only sees the signed event; NIP-44 encryption stays with the user's signer.3+(() => {4+ "use strict";5+6+ const validURL = value => {7+ try {8+ const parsed = new URL(String(value).trim());9+ return (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&10+ parsed.hostname !== "" && !parsed.username && !parsed.password &&11+ parsed.search === "" && parsed.hash === "";12+ } catch { return false; }13+ };14+ const relay = value => {15+ const parsed = new URL(String(value || "").trim());16+ parsed.hostname = parsed.hostname.toLowerCase();17+ if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/+$/, "");18+ return parsed.toString().replace(/\/$/, "");19+ };20+ const mergeRows = (rows, urls) => rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));21+ const signer = () => window.tinySigner || window.nostr;22+ const publicKey = async () => {23+ const active = signer();24+ if (active?.getPublicKey) return active.getPublicKey();25+ if (window.tiny?.getPublicKey) return window.tiny.getPublicKey();26+ throw Error("Connect a signer first.");27+ };28+ const encrypt = async (pubkey, text) => {29+ const active = signer();30+ if (active?.nip44Encrypt) return active.nip44Encrypt(pubkey, text);31+ if (active?.nip44?.encrypt) return active.nip44.encrypt(pubkey, text);32+ if (window.tiny?.nip44Encrypt) return window.tiny.nip44Encrypt(pubkey, text);33+ throw Error("The connected signer does not support NIP-44.");34+ };35+ const decrypt = async (pubkey, text) => {36+ const active = signer();37+ if (active?.nip44Decrypt) return active.nip44Decrypt(pubkey, text);38+ if (active?.nip44?.decrypt) return active.nip44.decrypt(pubkey, text);39+ if (window.tiny?.nip44Decrypt) return window.tiny.nip44Decrypt(pubkey, text);40+ throw Error("The connected signer does not support NIP-44.");41+ };42+ const readList = async pubkey => {43+ const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);44+ const response = await window.tiny.signedFetch("/query", "POST", body, {contentType: "application/json"});45+ if (!response || response.ok === false) throw Error("Could not read the private relay list.");46+ const rows = await response.json();47+ if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");48+ const event = rows[0];49+ if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid.");50+ return event;51+ };52+ const acceptEvent = (event, expectedPubkey) => {53+ if (!event || event.kind !== 10318 || event.pubkey !== expectedPubkey || !Array.isArray(event.tags) || typeof event.content !== "string") return false;54+ if (typeof window.NostrSigner?.verifyEvent !== "function") return false;55+ try { return window.NostrSigner.verifyEvent(event) === true; } catch { return false; }56+ };57+ const publish = async event => {58+ const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});59+ const text = await response.text();60+ let value;61+ try { value = JSON.parse(text); } catch { value = undefined; }62+ if (value?.error || value?.accepted === false) throw Error(value.error || value.message || "The relay rejected the event.");63+ };64+ const wireEvent = content => ({kind: 10318, created_at: Math.floor(Date.now() / 1000), tags: [], content});65+66+ class PrivateServices extends HTMLElement {67+ connectedCallback() {68+ if (this.bound) return;69+ this.bound = true;70+ this.form = this.querySelector("form");71+ this.lines = this.querySelector("textarea[name=lines]");72+ this.output = this.querySelector("output");73+ this.form?.addEventListener("submit", event => {74+ event.preventDefault();75+ if (this.busy) return;76+ this.busy = true;77+ this.form.querySelector("button").disabled = true;78+ this.save().catch(error => this.say("Error: " + error.message, true)).finally(() => {79+ this.busy = false;80+ this.form.querySelector("button").disabled = false;81+ });82+ });83+ this.whenSigned(() => this.load().then(() => { this.form.querySelector("button").disabled = false; }).catch(error => this.say("Error: " + error.message, true)));84+ }85+86+ whenSigned(fn, tries = 20) {87+ if (signer()?.signEvent || window.nostr?.signEvent) return fn();88+ if (tries <= 0) { this.say("Connect a signer, then reload this page."); return; }89+ setTimeout(() => this.whenSigned(fn, tries - 1), 500);90+ }91+92+ say(text, error) {93+ this.output.textContent = text;94+ if (error) this.output.dataset.error = "";95+ else delete this.output.dataset.error;96+ }97+98+ async load() {99+ this.say("Reading encrypted list…");100+ const pubkey = await publicKey();101+ const event = await readList(pubkey);102+ if (!event) { this.say("No private relay list published yet."); return; }103+ const rows = JSON.parse(await decrypt(pubkey, event.content));104+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");105+ const urls = rows.filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1])).map(row => relay(row[1]));106+ this.lines.value = urls.join("\n");107+ this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s") + ".");108+ }109+110+ async save() {111+ const rawURLs = this.lines.value.split(/\r?\n/).map(value => value.trim()).filter(Boolean);112+ if (rawURLs.some(value => !validURL(value))) throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");113+ const urls = rawURLs.map(relay);114+ const unique = [...new Set(urls)];115+ const pubkey = await publicKey();116+ const existing = await readList(pubkey);117+ let rows = [];118+ if (existing) {119+ rows = JSON.parse(await decrypt(pubkey, existing.content));120+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");121+ }122+ rows = mergeRows(rows, unique);123+ const content = await encrypt(pubkey, JSON.stringify(rows));124+ const active = signer();125+ if (!active?.signEvent) throw Error("Connect a signer first.");126+ const event = await active.signEvent(wireEvent(content));127+ if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0) throw Error("The signer returned an invalid private relay list event.");128+ await publish(event);129+ this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s") + ".");130+ }131+ }132+133+ window.PrivateServices = {validURL, canonicalURL: relay, mergeRows, encrypt, decrypt, wireEvent, acceptEvent, readList};134+ customElements.define("private-services", PrivateServices);135+})();@@ -112,7 +112,7 @@ rpc-form label,signed-form label,publish-list label,file-tools label,file-worksp
112112 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}113113 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%}114114 rpc-form label:has(textarea),publish-list label:has(textarea){display:block}115-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}115+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}116116 rpc-form output[data-error],file-tools output[data-error],file-workspace output[data-error]{color:var(--alert)}117117 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}118118 file-workspace-root button{background:transparent;border:0;padding:0;color:var(--accent);font:inherit;text-align:left}@@ -41,6 +41,9 @@ var blossomManifestsJS string
4141 //go:embed file-messages.js4242 var fileMessagesJS string4343 44+//go:embed private-services.js45+var privateServicesJS string46+4447 //go:embed blossom-upload.js4548 var blossomUploadJS string4649 @@ -125,7 +128,7 @@ func repoView(query url.Values) string {
125128 }126129 127130 func parseTemplates() (*template.Template, error) {128- 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}131+ 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}129132 funcs := template.FuncMap{130133 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },131134 "script": func(name string) (template.JS, error) {@@ -322,7 +322,7 @@ func TestConnectPageEditsConnectionsAsList(t *testing.T) {
322322 recorder := httptest.NewRecorder()323323 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))324324 body := recorder.Body.String()325- for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`} {325+ for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`, "<private-services", "kind 10318", "NIP-44"} {326326 if !strings.Contains(body, want) {327327 t.Errorf("connect page missing %q", want)328328 }@@ -3,6 +3,7 @@ import { finalizeEvent } from "nostr-tools/pure";
33 import { getToken } from "nostr-tools/nip98";44 55 const url = process.argv[2] || "http://127.0.0.1:17447";6+const privateRepos = process.argv.includes("--private");67 const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex"));78 async function rpc(method, params = []) {89 const payload = {method, params};@@ -15,5 +16,6 @@ async function rpc(method, params = []) {
1516 return result.result;1617 }1718 const policy = await rpc("getpolicy");18-policy.features = {...policy.features, grasp: true, grasp02: false, grasp03: false, grasp05: false, grasp06: false};19+policy.features = {...policy.features, grasp: true, grasp02: false, grasp03: false, grasp05: false, grasp06: false, grasp08: privateRepos};20+if (privateRepos) policy.reads = "members";1921 console.log(JSON.stringify(await rpc("setpolicy", [policy])));@@ -0,0 +1,108 @@
1+// GRASP-08 interoperability uses stock Git Smart HTTP with one reusable,2+// repository-root NIP-98 proof shared through git's extraHeader option.3+import { execFile } from "node:child_process";4+import { mkdtemp, rm, writeFile } from "node:fs/promises";5+import { tmpdir } from "node:os";6+import { promisify } from "node:util";7+import { describe, expect, it } from "vitest";8+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";9+import { npubEncode } from "nostr-tools/nip19";10+import { getToken } from "nostr-tools/nip98";11+import { sha256 } from "@noble/hashes/sha2.js";12+import { hexToBytes } from "@noble/hashes/utils.js";13+import { Client, HTTP_URL, RELAY_URL, now } from "./helpers.ts";14+15+const exec = promisify(execFile);16+const KIND_REPO = 30617;17+const KIND_REPO_STATE = 30618;18+const OWNER_SK = process.env.CLAIM_SK ? hexToBytes(process.env.CLAIM_SK) : sha256(new TextEncoder().encode("tiny conformance owner"));19+20+async function git(cwd: string, ...args: string[]): Promise<string> {21+ const result = await exec("git", args, {22+ cwd,23+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },24+ });25+ return result.stdout.trim();26+}27+28+function auth(sk: Uint8Array, root: string): string {29+ const event = finalizeEvent({ kind: 27235, created_at: now(), content: "", tags: [["u", root], ["method", "GET"]] }, sk);30+ return `Nostr ${Buffer.from(JSON.stringify(event)).toString("base64")}`;31+}32+33+async function rpc(method: string, params: unknown[] = []): Promise<any> {34+ const payload = { method, params };35+ const token = await getToken(HTTP_URL, "POST", event => finalizeEvent(event, OWNER_SK), true, payload);36+ const response = await fetch(HTTP_URL, {37+ method: "POST",38+ headers: { "Content-Type": "application/nostr+json+rpc", Authorization: token },39+ body: JSON.stringify(payload),40+ });41+ const result: any = await response.json();42+ if (!response.ok || result.error) throw new Error(JSON.stringify(result));43+ return result.result;44+}45+46+async function waitForRefs(url: string, authorization: string): Promise<void> {47+ for (let attempt = 0; attempt < 60; attempt++) {48+ const response = await fetch(`${url}/info/refs?service=git-upload-pack`, { headers: { authorization } });49+ const text = await response.text();50+ if (response.status === 200 && text.includes("# service=git-upload-pack")) return;51+ await new Promise(resolve => setTimeout(resolve, 100));52+ }53+ throw new Error("GRASP-08 repository did not become available");54+}55+56+describe("GRASP-08 stock Git", () => {57+ it("reuses one repository-root proof for clone, fetch and push", async ({ skip }) => {58+ if (process.env.TINY_GRASP08 !== "1") skip();59+ const policy = await rpc("getpolicy");60+ policy.features = { ...policy.features, grasp: true, grasp08: true };61+ policy.reads = "members";62+ await rpc("setpolicy", [policy]);63+ const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } });64+ const info: any = await infoResponse.json();65+ expect(info.supported_grasps).toContain("GRASP-08");66+67+ const root = await mkdtemp(`${tmpdir()}/tiny-grasp08-`);68+ const clone = await mkdtemp(`${tmpdir()}/tiny-grasp08-clone-`);69+ const sk = OWNER_SK;70+ const pubkey = getPublicKey(sk);71+ const npub = npubEncode(pubkey);72+ const identifier = `stock-${Math.random().toString(36).slice(2, 10)}`;73+ const repoURL = `${HTTP_URL}/${npub}/${encodeURIComponent(identifier)}.git`;74+ const proof = auth(sk, repoURL);75+ const c = await Client.connect(RELAY_URL);76+ try {77+ await c.auth(OWNER_SK, RELAY_URL);78+ await git(root, "init", "--initial-branch=main");79+ await git(root, "config", "user.email", "grasp08-conformance@example.com");80+ await git(root, "config", "user.name", "GRASP-08 conformance");81+ await writeFile(`${root}/README.md`, "private GRASP-08 repository\n");82+ await git(root, "add", "README.md");83+ await git(root, "commit", "-m", "initial");84+ const first = await git(root, "rev-parse", "HEAD");85+ const createdAt = now();86+ await c.publish(finalizeEvent({ kind: KIND_REPO, content: "", created_at: createdAt, tags: [["d", identifier], ["clone", repoURL], ["relays", RELAY_URL], ["maintainers", pubkey], ["private", "true"]] }, sk));87+ 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));88+ const header = `Authorization: ${proof}`;89+ await waitForRefs(repoURL, proof);90+ await git(root, "-c", `http.extraHeader=${header}`, "push", repoURL, "HEAD:refs/heads/main");91+ await git(clone, "-c", `http.extraHeader=${header}`, "clone", repoURL, ".");92+ expect(await git(clone, "rev-parse", "HEAD")).toBe(first);93+94+ await writeFile(`${root}/README.md`, "private GRASP-08 incremental repository\n");95+ await git(root, "add", "README.md");96+ await git(root, "commit", "-m", "incremental");97+ const second = await git(root, "rev-parse", "HEAD");98+ 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));99+ await waitForRefs(repoURL, proof);100+ await git(root, "-c", `http.extraHeader=${header}`, "push", repoURL, "HEAD:refs/heads/main");101+ await git(clone, "-c", `http.extraHeader=${header}`, "fetch", repoURL, "refs/heads/main:refs/remotes/origin/main");102+ expect(await git(clone, "rev-parse", "refs/remotes/origin/main")).toBe(second);103+ } finally {104+ c.close();105+ await Promise.all([rm(root, { recursive: true, force: true }), rm(clone, { recursive: true, force: true })]);106+ }107+ }, 120_000);108+});@@ -5,6 +5,7 @@ import { appendFile, mkdtemp, mkdir, readdir, rm, writeFile, rename } from "node
55 import { tmpdir } from "node:os";66 import { resolve, join, basename } from "node:path";77 import { promisify } from "node:util";8+import { randomUUID } from "node:crypto";89 import WebSocket from "ws";910 import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";1011 import { npubEncode } from "nostr-tools/nip19";@@ -19,6 +20,10 @@ const outDir = resolve(option("--out", "artifacts/git-performance/run"));
1920 const baseline = option("--baseline", "").replace(/\/$/, "");2021 const only = option("--only", "").split(",").filter(Boolean);2122 const privateRepos = args.includes("--private");23+const privateRunID = randomUUID().slice(0, 8);24+const privateOwnerSecretHex = option("--private-owner-secret", process.env.TINY_BENCHMARK_PRIVATE_OWNER_SECRET || "fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd");25+if (privateRepos && !/^[0-9a-fA-F]{64}$/.test(privateOwnerSecretHex)) throw Error("--private-owner-secret must be 64 hex characters");26+const privateOwnerSecret = privateRepos ? Uint8Array.from(Buffer.from(privateOwnerSecretHex, "hex")) : null;2227 const repeats = Math.max(1, Number(option("--repeats", "3")));2328 if (!reposDir) throw Error("--repos-dir is required");2429 const httpURL = relay.replace(/^ws/, "http").replace(/\/$/, "");@@ -154,16 +159,16 @@ async function benchmarkFixture(fixture, index) {
154159 const refs = await refsFor(fixture);155160 result.head = head;156161 result.refs = refs.length;157- const sk = generateSecretKey(), pubkey = getPublicKey(sk), id = `${name.replace(/\.git$/, "")}-${index}`;162+ const sk = privateRepos ? privateOwnerSecret : generateSecretKey(), pubkey = getPublicKey(sk), id = `${name.replace(/\.git$/, "")}-${index}${privateRepos ? "-" + privateRunID : ""}`;158163 const repositoryPath = `/${npubEncode(pubkey)}/${encodeURIComponent(id)}.git`;159164 const canonicalURL = httpURL + repositoryPath;160165 let url = canonicalURL;161166 result.url = canonicalURL;162167 if (privateRepos) {163168 await control.authenticate(sk);164- signingProxy = await startGitSigningProxy(httpURL, sk);169+ signingProxy = await startGitSigningProxy(httpURL, sk, {grasp08: true});165170 url = signingProxy.url + repositoryPath;166- result.transport = "local NIP-98 signing proxy; request hash spooled on client";171+ result.transport = "local GRASP-08 signing proxy; request body spooled on client";167172 }168173 const announceAt = Math.floor(Date.now()/1000);169174 const announcement = finalizeEvent({kind: 30617, created_at: announceAt, content: "", tags: [["d", id], ["clone", canonicalURL], ["relays", relay], ["maintainers", pubkey], ...(privateRepos ? [["private", "true"]] : [])]}, sk);@@ -17,7 +17,7 @@ import { finalizeEvent } from "nostr-tools/pure";
1717 // startGitSigningProxy forwards to target (an http or https base URL). The1818 // signer is either a secret key (Uint8Array) or an async function that takes1919 // an event template and returns the signed event, such as a NIP-46 bunker.20-export async function startGitSigningProxy(target, signer) {20+export async function startGitSigningProxy(target, signer, options = {}) {2121 const sign = typeof signer === "function" ? signer : template => finalizeEvent(template, signer);2222 const server = http.createServer(async (request, response) => {2323 let directory;@@ -30,8 +30,22 @@ export async function startGitSigningProxy(target, signer) {
3030 hash.update(chunk); size += chunk.length; done(null, chunk);3131 }}), createWriteStream(path));3232 const url = new URL(target + request.url);33- const tags = [["u", url.href], ["method", request.method], ["nonce", randomUUID()]];34- if (size) tags.push(["payload", hash.digest("hex")]);33+ let proofURL = url.href;34+ let proofMethod = request.method;35+ if (options.grasp08) {36+ const marker = url.pathname.lastIndexOf(".git");37+ if (marker < 0 || !["", "/"].includes(url.pathname.slice(marker + 4, marker + 5))) {38+ throw Error("GRASP08 signing requires a repository URL");39+ }40+ proofURL = new URL(url.href);41+ proofURL.pathname = url.pathname.slice(0, marker + 4);42+ proofURL.search = "";43+ proofURL.hash = "";44+ proofURL = proofURL.href;45+ proofMethod = "GET";46+ }47+ const tags = [["u", proofURL], ["method", proofMethod], ["nonce", randomUUID()]];48+ if (size && !options.grasp08) tags.push(["payload", hash.digest("hex")]);3549 const proof = await sign({kind: 27235, created_at: Math.floor(Date.now()/1000), tags, content: ""});3650 const headers = {...request.headers, host: url.host, "content-length": String(size),3751 authorization: "Nostr " + Buffer.from(JSON.stringify(proof)).toString("base64")};@@ -0,0 +1,77 @@
1+import assert from "node:assert/strict";2+import {readFile} from "node:fs/promises";3+import test from "node:test";4+import vm from "node:vm";5+6+const source = await readFile(new URL("../internal/webui/private-services.js", import.meta.url), "utf8");7+class Element {}8+const sandbox = {9+ URL, TextEncoder, HTMLElement: Element,10+ document: {}, Response,11+ customElements: {define() {}},12+ tinySigner: {13+ getPublicKey: async () => "a".repeat(64),14+ nip44Encrypt: async (_pubkey, value) => "enc:" + value,15+ nip44Decrypt: async (_pubkey, value) => value.replace(/^enc:/, "")16+ },17+ nostr: {signEvent: async event => event},18+ NostrSigner: {verifyEvent: () => true}19+};20+sandbox.window = sandbox;21+vm.runInNewContext(source, sandbox);22+const api = sandbox.PrivateServices;23+24+test("accepts tenant paths while rejecting unsafe relay URLs", () => {25+ assert.equal(api.validURL("wss://Relay.Example/r/Private"), true);26+ assert.equal(api.canonicalURL("wss://Relay.Example/r/Private/"), "wss://relay.example/r/Private");27+ assert.equal(api.validURL("wss://relay.example/?token=secret"), false);28+ assert.equal(api.validURL("wss://user:pass@relay.example"), false);29+ assert.equal(api.validURL("https://relay.example"), false);30+});31+32+test("NIP-44 helpers round-trip and retain unrelated private entries", async () => {33+ const clear = JSON.stringify([["name", "work"], ["g", "wss://old.example"], ["p", "b".repeat(64)]]);34+ const encrypted = await api.encrypt("a".repeat(64), clear);35+ assert.equal(await api.decrypt("a".repeat(64), encrypted), clear);36+ 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"]]);37+});38+39+test("NIP-07 nip44 methods are accepted alongside bunker methods", async () => {40+ sandbox.tinySigner = null;41+ sandbox.nostr = {nip44: {42+ encrypt: async (_pubkey, value) => "nip07:" + value,43+ decrypt: async (_pubkey, value) => value.replace(/^nip07:/, "")44+ }};45+ const encrypted = await api.encrypt("a".repeat(64), "private");46+ assert.equal(encrypted, "nip07:private");47+ assert.equal(await api.decrypt("a".repeat(64), encrypted), "private");48+});49+50+test("kind 10318 wire has no public tags", () => {51+ const event = api.wireEvent("ciphertext");52+ assert.equal(event.kind, 10318);53+ assert.equal(JSON.stringify(event.tags), "[]");54+ assert.equal(event.content, "ciphertext");55+});56+57+test("reads only a verified kind 10318 event for the requested author", async () => {58+ const pubkey = "a".repeat(64);59+ const event = {kind: 10318, pubkey, tags: [], content: "ciphertext", id: "1", sig: "2"};60+ const requests = [];61+ sandbox.tiny = {signedFetch: async (_path, _method, body) => {62+ requests.push(JSON.parse(body));63+ return new Response(JSON.stringify([event]), {status: 200});64+ }};65+ sandbox.NostrSigner.verifyEvent = () => true;66+ assert.deepEqual(JSON.parse(JSON.stringify(await api.readList(pubkey))), event);67+ assert.deepEqual(JSON.parse(JSON.stringify(requests[0])), [{kinds: [10318], authors: [pubkey], limit: 1}]);68+ sandbox.tiny.signedFetch = async () => new Response(JSON.stringify([{...event, pubkey: "b".repeat(64)}]), {status: 200});69+ await assert.rejects(api.readList(pubkey), /invalid/);70+ sandbox.tiny.signedFetch = async () => new Response(JSON.stringify({items: []}), {status: 200});71+ await assert.rejects(api.readList(pubkey), /malformed/);72+});73+74+test("rejects a failed event signature", () => {75+ sandbox.NostrSigner.verifyEvent = () => false;76+ assert.equal(api.acceptEvent({kind: 10318, pubkey: "a".repeat(64), tags: [], content: ""}, "a".repeat(64)), false);77+});