clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

add GRASP-08 private tenants with repository-root proofs and encrypted service lists

Dami committed f3b260b
commit
f3b260bcec34624519dda50138f1a0417da4adfd
parent
8b51870933f832641be527f6d6d57c99f97f9c5f
author
Dami <dami.etoile@gmail.com>
38 files changed, +1096 -56
Minternal/auth/auth_test.go67++++++++++++++
Ainternal/auth/grasp08.go71+++++++++++++++
Minternal/configport/configport.go7++
Minternal/daemon/capabilities.go9++
Minternal/daemon/daemon_test.go6+-
Minternal/daemon/git_private_restart_test.go10+-
Ainternal/daemon/grasp_capabilities_test.go43+++++++++
Minternal/daemon/http.go22++---
Minternal/daemon/private_grasp.go4+-
Minternal/daemon/private_grasp_test.go8+-
Minternal/daemon/private_peer.go10++-
Minternal/daemon/private_peer_acceptance_test.go11++-
Ainternal/daemon/private_repository.go49+++++++++++
Ainternal/daemon/private_repository_test.go139++++++++++++++++++++++++++++++
Minternal/daemon/services.go7+-
Minternal/daemon/tenant.go33+++++++-
Minternal/gates/gates.go17++++
Ainternal/gitrelay/capabilities_test.go34+++++++
Minternal/gitrelay/gitrelay.go39+++++++-
Minternal/gitrelay/private_transport.go26++++--
Minternal/gitrelay/private_transport_test.go4+-
Minternal/policy/access.go18++++
Minternal/policy/policy_test.go15+++
Minternal/replication/delivery.go17+++-
Ainternal/replication/private_delivery_test.go70+++++++++++++++
Ainternal/storage/private_repository.go36++++++++
Minternal/webui/components.js3+-
Minternal/webui/manage.html7++
Minternal/webui/page.html6+
Ainternal/webui/private-services.js135+++++++++++++++++++++++++++++
Minternal/webui/style.css2+-
Minternal/webui/templates.go5+-
Minternal/webui/webui_test.go2+-
Mscripts/configure-git-performance.mjs4+-
Ascripts/conformance/grasp08.test.ts108+++++++++++++++++++++++
Mscripts/git-performance.mjs11++-
Mscripts/git-signing-proxy.mjs20++++-
Ascripts/private-services.test.mjs77+++++++++++++++++
M internal/auth/auth_test.go +67 -0
@@ -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+}
A internal/auth/grasp08.go +71 -0
@@ -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+}
M internal/configport/configport.go +7 -0
M internal/daemon/capabilities.go +9 -0
M internal/daemon/daemon_test.go +5 -1
M internal/daemon/git_private_restart_test.go +6 -4
@@ -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 }
A internal/daemon/grasp_capabilities_test.go +43 -0
M internal/daemon/http.go +10 -12
@@ -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()
M internal/daemon/private_grasp.go +2 -2
M internal/daemon/private_grasp_test.go +6 -2
@@ -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 }
M internal/daemon/private_peer.go +9 -1
M internal/daemon/private_peer_acceptance_test.go +9 -2
A internal/daemon/private_repository.go +49 -0
@@ -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+}
A internal/daemon/private_repository_test.go +139 -0
@@ -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+}
M internal/daemon/services.go +5 -2
@@ -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
M internal/daemon/tenant.go +31 -2
@@ -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 {
M internal/gates/gates.go +17 -0
A internal/gitrelay/capabilities_test.go +34 -0
M internal/gitrelay/gitrelay.go +34 -5
@@ -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 {
M internal/gitrelay/private_transport.go +17 -9
@@ -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
M internal/gitrelay/private_transport_test.go +2 -2
M internal/policy/access.go +18 -0
M internal/policy/policy_test.go +15 -0
M internal/replication/delivery.go +16 -1
@@ -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 {
A internal/replication/private_delivery_test.go +70 -0
@@ -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+}
A internal/storage/private_repository.go +36 -0
M internal/webui/components.js +2 -1
M internal/webui/manage.html +7 -0
M internal/webui/page.html +6 -0
A internal/webui/private-services.js +135 -0
@@ -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+})();
M internal/webui/style.css +1 -1
M internal/webui/templates.go +4 -1
M internal/webui/webui_test.go +1 -1
M scripts/configure-git-performance.mjs +3 -1
A scripts/conformance/grasp08.test.ts +108 -0
@@ -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+});
M scripts/git-performance.mjs +8 -3
@@ -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);
M scripts/git-signing-proxy.mjs +17 -3
@@ -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")};
A scripts/private-services.test.mjs +77 -0
@@ -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+});