diff --git a/README.md b/README.md index fd68a10..be7b0c1 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ When a response includes `path`, it also includes `path_scores` in the same orde ```json [ {"did": "did:plc:source", "assertion": null, "score": 3}, - {"did": "did:plc:intermediate", "assertion": 2, "score": 2}, + {"did": "did:plc:intermediate", "assertion": 2, "score": 2, "comment": "Reference text from the source."}, {"did": "did:plc:target", "assertion": 1, "score": 0.9} ] ``` @@ -271,6 +271,14 @@ It is not an independent best-path calculation for that node. The source has no incoming assertion and uses the self score of 3. Direct blocks retain score -3. Unknown results contain neither path field. +Vouch records accept an optional `comment` string for public reference text. +Comments contain at most 300 Unicode code points. +The authenticated `PUT /api/vouch` endpoint accepts this field with `subject`, `tag`, and `score`. +Each `path_scores` node includes the comment from its incoming vouch, when that comment is not empty. +The source node has no incoming comment. Existing records without comments remain valid. +Comments do not affect scores, path selection, tags, or record keys. +Clients must treat comments as untrusted text, not HTML. + The score equals the maximum path bottleneck minus its additional-hop penalty, clamped to [-2, 2]. A direct block by the querying user returns -3 and overrides all paths. Other blocks participate in graph calculations as -3. diff --git a/docs/security-review.md b/docs/security-review.md index b9a87df..f796546 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -30,6 +30,7 @@ - Invalid record updates cannot preserve an obsolete positive assertion. - Microcosm endpoints come only from operator configuration, not browser input. - Missing payload lookup requires the stream's exact URI and CID. +- Vouch comments are public, untrusted text. Clients must not interpret them as HTML. ## Correctness @@ -41,6 +42,8 @@ Direct blocks override paths. Other blocks participate in calculations. Optimistic selects the highest score across paths. ShortestPath selects the fewest hops, then the highest score among equal-length paths. ShortestPath removes ignored intermediaries and their edges before traversal. A direct block remains effective. Unknown targets never become trusted through a negative threshold. +The service attaches incoming vouch comments after calculation. Comments do not affect scores, path selection, or record keys. +The schema migration gives existing records an empty comment and preserves their assertions. ## Architecture diff --git a/internal/appview/comments_test.go b/internal/appview/comments_test.go new file mode 100644 index 0000000..b2a1a5e --- /dev/null +++ b/internal/appview/comments_test.go @@ -0,0 +1,75 @@ +package appview + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "provenance/internal/domain" + "provenance/internal/trust" + "provenance/internal/trust/algorithms" +) + +func TestPathComments(t *testing.T) { + for _, algorithm := range []trust.Algorithm{algorithms.Optimistic{}, algorithms.ShortestPath{}} { + t.Run(algorithm.Describe().Name, func(t *testing.T) { + service, _ := algorithmService(t, algorithm) + ctx := context.Background() + for _, blocked := range []bool{false, true} { + edges := []domain.Vouch{ + {Author: alice, Subject: carol, Score: -1, Comment: "Weak path."}, + {Author: alice, Subject: dan, Score: 2, Comment: "Strong path."}, + {Author: alice, Subject: dan, Tag: "work", Score: 2, Comment: "Topic reference."}, + } + if blocked { + edges = append(edges, domain.Vouch{Author: alice, Subject: bob, Score: domain.Blocked, Comment: "Block reference."}) + } + if err := service.store.Replace(ctx, alice, edges, time.Now()); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + Handler(service, serviceKey).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/reputation?source="+alice+"&target="+bob+"&tag=work&mode=fallback&include_path=true", nil)) + var got trust.Result + if w.Code != http.StatusOK { + t.Fatalf("response: %d %s", w.Code, w.Body) + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + want := "Topic reference." + if algorithm.Describe().Name == (algorithms.ShortestPath{}).Describe().Name { + want = "Weak path." + } + if blocked { + want = "Block reference." + } + if len(got.PathScores) < 2 || got.PathScores[0].Comment != "" || got.PathScores[1].Comment != want { + t.Fatalf("comments: %+v", got) + } + for _, node := range got.PathScores[2:] { + if node.Comment != "" { + t.Fatalf("unexpected comment: %+v", node) + } + } + } + }) + } +} + +func TestCommentResultCopy(t *testing.T) { + score := 2.0 + assertion := 2 + original := trust.Result{Status: trust.CalculatedStatus, Score: &score, Path: []string{alice, bob}, PathScores: []trust.PathScore{ + {DID: alice, Score: domain.SelfScore}, {DID: bob, Assertion: &assertion, Score: score}, + }} + got := pathComments(original, []domain.Vouch{{Author: alice, Subject: bob, Score: assertion, Comment: "Reference only."}}) + if original.PathScores[1].Comment != "" { + t.Fatal("changed an algorithm result buffer") + } + if got.Score != original.Score || got.PathScores[1].Score != score || got.PathScores[1].Assertion != &assertion || got.PathScores[1].Comment != "Reference only." { + t.Fatalf("changed calculation: %+v", got) + } +} diff --git a/internal/appview/openapi.go b/internal/appview/openapi.go index 93f27de..e12f0b1 100644 --- a/internal/appview/openapi.go +++ b/internal/appview/openapi.go @@ -131,12 +131,13 @@ func apiSchemas() map[string]any { evaluation[name] = schema } evaluation["trusted"] = scalarSchema("boolean") + comment := map[string]any{"type": "string", "maxLength": domain.MaxCommentLength, "description": "Reference text from the incoming vouch. This text does not affect trust calculations."} return map[string]any{ "Result": objectSchema(properties, "status", "score"), "Evaluation": objectSchema(evaluation, "status", "score", "trusted"), - "PathScore": objectSchema(map[string]any{"did": stringSchema, "assertion": map[string]any{"type": "integer", "nullable": true, "minimum": domain.Blocked, "maximum": domain.MaxScore}, "score": score}, "did", "assertion", "score"), + "PathScore": objectSchema(map[string]any{"did": stringSchema, "assertion": map[string]any{"type": "integer", "nullable": true, "minimum": domain.Blocked, "maximum": domain.MaxScore}, "score": score, "comment": comment}, "did", "assertion", "score"), "Error": objectSchema(map[string]any{"error": stringSchema}, "error"), - "Vouch": objectSchema(map[string]any{"author": stringSchema, "subject": stringSchema, "score": map[string]any{"type": "integer", "minimum": domain.Blocked, "maximum": domain.MaxScore}, "tag": stringSchema, "createdAt": stringSchema}, "author", "subject", "score", "tag"), + "Vouch": objectSchema(map[string]any{"author": stringSchema, "subject": stringSchema, "score": map[string]any{"type": "integer", "minimum": domain.Blocked, "maximum": domain.MaxScore}, "tag": stringSchema, "createdAt": stringSchema, "comment": comment}, "author", "subject", "score", "tag"), "Participant": objectSchema(map[string]any{"did": stringSchema, "threshold": scalarSchema("number"), "active": scalarSchema("boolean"), "syncedAt": stringSchema, "bootstrapUs": scalarSchema("integer")}, "did", "threshold", "active", "syncedAt", "bootstrapUs"), } } diff --git a/internal/appview/service.go b/internal/appview/service.go index cda7568..59120c3 100644 --- a/internal/appview/service.go +++ b/internal/appview/service.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "log/slog" + "slices" "strings" "sync" "time" @@ -111,7 +112,30 @@ func (s *Service) Reputation(ctx context.Context, source, target, tag string, mo return trust.Result{}, trust.ErrLimit } input.Edges = trust.Select(edges, tag, mode) - return s.algorithm.Calculate(ctx, input) + result, err := s.algorithm.Calculate(ctx, input) + if err != nil { + return trust.Result{}, err + } + return pathComments(result, input.Edges), nil +} + +// Attach reference text after calculation so that algorithms never select paths by comment content. +func pathComments(result trust.Result, edges []domain.Vouch) trust.Result { + if len(result.PathScores) == 0 { + return result + } + comments := make(map[[2]string]string, len(edges)) + for _, edge := range edges { + comments[[2]string{edge.Author, edge.Subject}] = edge.Comment + } + // Copy the returned nodes because an algorithm can reuse its result buffers. + result.PathScores = slices.Clone(result.PathScores) + result.PathScores[0].Comment = "" + for i := 1; i < len(result.PathScores); i++ { + pair := [2]string{result.PathScores[i-1].DID, result.PathScores[i].DID} + result.PathScores[i].Comment = comments[pair] + } + return result } func (s *Service) resolveInput(ctx context.Context, source, target string, options []trust.Accounts) (trust.Input, error) { diff --git a/internal/atproto/oauth.go b/internal/atproto/oauth.go index a5713fc..e033298 100644 --- a/internal/atproto/oauth.go +++ b/internal/atproto/oauth.go @@ -126,6 +126,9 @@ func (o *OAuth) PutVouch(ctx context.Context, session Session, v domain.Vouch) e if err := domain.ValidateTag(v.Tag); err != nil { return err } + if err := domain.ValidateComment(v.Comment); err != nil { + return err + } o.mu.Lock() defer o.mu.Unlock() sess, err := o.app.ResumeSession(ctx, syntax.DID(session.DID), session.ID) @@ -134,7 +137,7 @@ func (o *OAuth) PutVouch(ctx context.Context, session Session, v domain.Vouch) e } body := map[string]any{ "repo": session.DID, "collection": domain.Collection, "rkey": domain.RecordKey(v.Subject, v.Tag), - "record": Record{Type: domain.Collection, Subject: v.Subject, Score: &v.Score, Tag: v.Tag, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano)}, + "record": Record{Type: domain.Collection, Subject: v.Subject, Score: &v.Score, Tag: v.Tag, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), Comment: v.Comment}, } return sess.APIClient().Post(ctx, "com.atproto.repo.putRecord", body, nil) } diff --git a/internal/atproto/oauth_test.go b/internal/atproto/oauth_test.go index 48f5d69..f53bfd0 100644 --- a/internal/atproto/oauth_test.go +++ b/internal/atproto/oauth_test.go @@ -15,6 +15,7 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" + "provenance/internal/domain" "provenance/internal/secure" "provenance/internal/storage" ) @@ -146,3 +147,44 @@ func TestOAuthRejectsInvalidIdentity(t *testing.T) { }) } } + +func TestPublishComment(t *testing.T) { + client, state := oauthFixture(t, testDID, "atproto") + ctx := context.Background() + if _, err := client.Begin(ctx, "alice.example.com", "browser"); err != nil { + t.Fatal(err) + } + session, err := client.Finish(ctx, url.Values{"state": {*state}, "iss": {testPDS}, "code": {"code"}}, "browser") + if err != nil { + t.Fatal(err) + } + var published Record + client.app.Client.Transport = transport(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/xrpc/com.atproto.repo.putRecord" { + t.Fatalf("unexpected request: %s", r.URL) + } + var body struct { + Repo string + Collection string + Rkey string + Record Record + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Repo != testDID || body.Collection != domain.Collection || body.Rkey != domain.RecordKey(body.Record.Subject, body.Record.Tag) { + t.Fatalf("publication: %+v", body) + } + published = body.Record + return response(map[string]any{}), nil + }) + for _, comment := range []string{"A reference.\n日本語