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