- commit
- fe0e11cd305bc053375e215480da7a08588585f6
- parent
- 73cbaa220c80e458e9f0c5181641c705132bd6b8
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 148 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 113 | +++++++++++++++++++++++ |
@@ -17,12 +17,14 @@ import (
1717 "strings"1818 1919 "github.com/FelineStateMachine/tinyrelay/internal/event"20+ "github.com/FelineStateMachine/tinyrelay/internal/sites"2021 )2122 2223 const (2324 AgentRateDefault = 602425 AgentRateMax = 6002526 AgentGrantMaxDays = 36527+ AgentSiteTTLMax = 3652628 agentNameMax = 642729 agentRoomMax = 1282830 agentListMax = 256@@ -47,6 +49,16 @@ const (
4749 AgentJobsBoth = "both"4850 )4951 52+// AgentSite is one static site an agent may publish. Label is a site label53+// under the agent's own key or "*" for any of them. TTLDays, when set, bounds54+// how long a manifest and the files behind it live; Encrypted requires the55+// agent's uploads to be encrypted.56+type AgentSite struct {57+ Label string `json:"label"`58+ TTLDays int `json:"ttl,omitempty"`59+ Encrypted bool `json:"encrypted,omitempty"`60+}61+5062 // AgentScope is the part of a grant the gate checks on every event.5163 type AgentScope struct {5264 Kinds []int `json:"kinds"`@@ -54,6 +66,7 @@ type AgentScope struct {
5466 Repos []AgentRepo `json:"repos"`5567 Wiki string `json:"wiki,omitempty"`5668 Jobs string `json:"jobs,omitempty"`69+ Sites []AgentSite `json:"sites,omitempty"`5770 Rate int `json:"rate"`5871 }5972 @@ -103,6 +116,9 @@ func (g AgentGrant) AllowsKind(kind int) bool {
103116 if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() {104117 return true105118 }119+ if siteManifestKind(kind) && len(g.Scope.Sites) > 0 {120+ return true121+ }106122 // Wiki access carries its kinds: proposing means publishing a version and107123 // asking for a merge; editing adds redirects.108124 switch g.Scope.Wiki {@@ -114,6 +130,53 @@ func (g AgentGrant) AllowsKind(kind int) bool {
114130 return false115131 }116132 133+// siteManifestKind reports whether a kind is a site manifest a grant's sites134+// tag admits: the key's own site and named sites under it. Snapshots are135+// left out; they are copies of a site rather than a site the agent publishes.136+func siteManifestKind(kind int) bool {137+ return kind == sites.KindSite || kind == sites.KindNamedSite138+}139+140+// SiteEntries lists the grant's site entries that cover one label.141+func (g AgentGrant) SiteEntries(label string) []AgentSite {142+ var out []AgentSite143+ for _, site := range g.Scope.Sites {144+ if site.Label == "*" || (label != "" && site.Label == label) {145+ out = append(out, site)146+ }147+ }148+ return out149+}150+151+// UploadTTLDays is the number of days the agent's uploads live: the longest152+// ttl among its site entries, or 0 when there is none or an entry sets none.153+func (g AgentGrant) UploadTTLDays() int { return longestTTL(g.Scope.Sites) }154+155+// UploadsEncrypted reports whether the agent's uploads must be encrypted.156+// One entry with the flag applies it to every upload the agent makes, since157+// an upload does not say which site it is for.158+func (g AgentGrant) UploadsEncrypted() bool {159+ for _, site := range g.Scope.Sites {160+ if site.Encrypted {161+ return true162+ }163+ }164+ return false165+}166+167+func longestTTL(entries []AgentSite) int {168+ longest := 0169+ for _, site := range entries {170+ if site.TTLDays == 0 {171+ return 0172+ }173+ if site.TTLDays > longest {174+ longest = site.TTLDays175+ }176+ }177+ return longest178+}179+117180 // RequestsJobs reports whether the jobs tag lets the agent publish job118181 // requests.119182 func (g AgentGrant) RequestsJobs() bool {@@ -164,6 +227,9 @@ func (g AgentGrant) Check(e event.Event, now int64) error {
164227 if room := event.Tag(e, "h"); room != "" && !g.AllowsRoom(room) {165228 return errors.New("restricted: agent grant does not allow room " + room)166229 }230+ if siteManifestKind(e.Kind) {231+ return g.checkSite(e, now)232+ }167233 if !gitKind(e.Kind) {168234 return nil169235 }@@ -186,6 +252,30 @@ func (g AgentGrant) Check(e event.Event, now int64) error {
186252 return nil187253 }188254 255+// checkSite applies the sites entries to a manifest: the label must be256+// covered, and when the covering entries carry a ttl the manifest must257+// expire within it so NIP-40 retires the site on time.258+func (g AgentGrant) checkSite(e event.Event, now int64) error {259+ label := sites.SiteLabel(e)260+ entries := g.SiteEntries(label)261+ if len(entries) == 0 {262+ return fmt.Errorf("restricted: agent grant does not allow site %s", label)263+ }264+ ttl := longestTTL(entries)265+ if ttl == 0 {266+ return nil267+ }268+ latest := now + int64(ttl)*86400269+ expires := event.Expiration(e)270+ if expires == 0 {271+ return fmt.Errorf("invalid: agent grant allows site %s for %d days: add an expiration tag no later than %d", label, ttl, latest)272+ }273+ if expires > latest {274+ return fmt.Errorf("invalid: agent grant allows site %s for %d days: set the expiration tag no later than %d", label, ttl, latest)275+ }276+ return nil277+}278+189279 func gitKind(kind int) bool {190280 return kind == event.KIND_GIT_PATCH || kind == event.KIND_GIT_PR || kind == event.KIND_GIT_PR_UPDATE || kind == event.KIND_GIT_ISSUE || kind == 1111 || gitStatusKind(kind)191281 }@@ -286,6 +376,21 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
286376 default:287377 return AgentGrant{}, errors.New("invalid: agent grant jobs tag must be request, serve or both")288378 }379+ for _, tag := range e.Tags {380+ if len(tag) == 0 || tag[0] != "sites" {381+ continue382+ }383+ site, err := parseAgentSite(tag[1:], agent)384+ if err != nil {385+ return AgentGrant{}, err386+ }387+ if !siteListed(grant.Scope.Sites, site.Label) {388+ grant.Scope.Sites = append(grant.Scope.Sites, site)389+ }390+ }391+ if len(grant.Scope.Sites) > agentListMax {392+ return AgentGrant{}, fmt.Errorf("invalid: agent grant lists more than %d entries", agentListMax)393+ }289394 if value := event.Tag(e, "rate"); value != "" {290395 rate, err := strconv.Atoi(strings.TrimSpace(value))291396 if err != nil || rate < 1 || rate > AgentRateMax {@@ -313,6 +418,49 @@ func parseAgentRepo(value string) (AgentRepo, bool) {
313418 return AgentRepo{Owner: parts[0], Identifier: parts[1], Level: level}, true314419 }315420 421+// parseAgentSite reads one sites tag: a label, then any of ttl=<days> and422+// encrypted. The label is "*" or a site label under the agent's own key.423+func parseAgentSite(values []string, agent string) (AgentSite, error) {424+ if len(values) == 0 {425+ return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *")426+ }427+ site := AgentSite{Label: strings.TrimSpace(values[0])}428+ if site.Label != "*" {429+ parsed, ok := sites.ParseSite(site.Label)430+ if !ok || parsed.Kind == sites.KindSiteSnapshot {431+ return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *")432+ }433+ if parsed.PubKey != agent {434+ return AgentSite{}, errors.New("invalid: agent grant sites label must be a site under the agent's own key")435+ }436+ }437+ for _, flag := range values[1:] {438+ flag = strings.TrimSpace(flag)439+ switch {440+ case flag == "encrypted":441+ site.Encrypted = true442+ case strings.HasPrefix(flag, "ttl="):443+ days, err := strconv.Atoi(strings.TrimPrefix(flag, "ttl="))444+ if err != nil || days < 1 || days > AgentSiteTTLMax {445+ return AgentSite{}, fmt.Errorf("invalid: agent grant sites ttl must be between 1 and %d days", AgentSiteTTLMax)446+ }447+ site.TTLDays = days448+ default:449+ return AgentSite{}, errors.New("invalid: agent grant sites tag allows only ttl=<days> and encrypted after the label")450+ }451+ }452+ return site, nil453+}454+455+func siteListed(entries []AgentSite, label string) bool {456+ for _, site := range entries {457+ if site.Label == label {458+ return true459+ }460+ }461+ return false462+}463+316464 // CanGrantAgents reports whether a role may sign agent grants.317465 func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" }318466 @@ -12,6 +12,7 @@ import (
1212 "github.com/FelineStateMachine/tinyrelay/internal/event"1313 "github.com/FelineStateMachine/tinyrelay/internal/policy"1414 "github.com/FelineStateMachine/tinyrelay/internal/relay"15+ "github.com/FelineStateMachine/tinyrelay/internal/sites"1516 "github.com/FelineStateMachine/tinyrelay/internal/storage"1617 )1718 @@ -255,3 +256,115 @@ func TestAgentGrantShapeAndSigner(t *testing.T) {
255256 t.Fatalf("parsed grant %+v %v", grant, err)256257 }257258 }259+260+func TestAgentGrantSitesCoverLabelsAndRequireExpiry(t *testing.T) {261+ f := newAgentFixture(t)262+ ctx := context.Background()263+ session := relay.Session{PubKeys: []string{f.agent}}264+ hash := strings.Repeat("a", 64)265+ paths := [][]string{{"path", "/index.html", hash}}266+ own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent})267+ base, _ := sites.Base36(f.agent)268+ named := base + "docs"269+ manifest := func(kind int, tags ...[]string) event.Event {270+ return signed(t, agentSecret, kind, agentNow, append(append([][]string{}, paths...), tags...), "")271+ }272+ // Without a sites tag the manifest kinds stay closed.273+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))274+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "kind 15128")275+ // One label without a ttl: that site only, no expiration needed.276+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"sites", named}))277+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}), session, agentNow); err != nil {278+ t.Fatalf("covered named site rejected: %v", err)279+ }280+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}), session, agentNow), "site "+base+"blog")281+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "site "+own)282+ // A ttl requires an expiration tag within it; the reason says what to add.283+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"sites", own, "ttl=2"}, []string{"sites", named, "ttl=1", "encrypted"}))284+ latest := strconv.FormatInt(agentNow+2*86400, 10)285+ err := f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow)286+ if err == nil || !strings.HasPrefix(err.Error(), "invalid: agent grant allows site "+own+" for 2 days: add an expiration tag no later than "+latest) {287+ t.Fatalf("manifest without expiration: %v", err)288+ }289+ err = f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", strconv.FormatInt(agentNow+3*86400, 10)}), session, agentNow)290+ if err == nil || !strings.Contains(err.Error(), "set the expiration tag no later than "+latest) {291+ t.Fatalf("manifest expiring too late: %v", err)292+ }293+ if err := f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow); err != nil {294+ t.Fatalf("manifest within the ttl rejected: %v", err)295+ }296+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+86400, 10)}), session, agentNow); err != nil {297+ t.Fatalf("named manifest within its own ttl rejected: %v", err)298+ }299+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", latest}), session, agentNow); err == nil {300+ t.Fatal("named manifest beyond its own ttl accepted")301+ }302+ // A star covers every label under the key and the longest ttl applies.303+ grant := f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"sites", "*", "ttl=5"}, []string{"sites", named, "ttl=1"}))304+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil {305+ t.Fatalf("star label rejected: %v", err)306+ }307+ if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil {308+ t.Fatalf("longest ttl not applied: %v", err)309+ }310+ parsed, err := community.ParseAgentGrant(grant, agentNow)311+ if err != nil || parsed.UploadTTLDays() != 5 || parsed.UploadsEncrypted() {312+ t.Fatalf("parsed sites %+v %v", parsed.Scope.Sites, err)313+ }314+ // Snapshots are not admitted by the sites tag.315+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSiteSnapshot), session, agentNow), "kind 5128")316+ // Paused and revoked grants stop manifests like every other event.317+ if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, true); err != nil {318+ t.Fatal(err)319+ }320+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "paused")321+ if _, err := f.community.RevokeAgent(ctx, f.owner, f.agent, agentNow); err != nil {322+ t.Fatal(err)323+ }324+ expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "revoked")325+}326+327+func TestAgentGrantSitesShape(t *testing.T) {328+ f := newAgentFixture(t)329+ ctx := context.Background()330+ other, _ := event.PublicKey(agentOtherSecret)331+ own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent})332+ foreign := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: other})333+ for _, tc := range []struct {334+ name string335+ tag []string336+ want string337+ }{338+ {"empty", []string{"sites"}, "invalid: agent grant sites tag needs a site label"},339+ {"bad label", []string{"sites", "not-a-site"}, "invalid: agent grant sites tag needs a site label"},340+ {"other key", []string{"sites", foreign}, "invalid: agent grant sites label must be a site under the agent's own key"},341+ {"snapshot", []string{"sites", "v" + strings.Repeat("0", 50)}, "invalid: agent grant sites tag needs a site label"},342+ {"ttl zero", []string{"sites", "*", "ttl=0"}, "invalid: agent grant sites ttl must be between 1 and 365"},343+ {"ttl long", []string{"sites", "*", "ttl=366"}, "invalid: agent grant sites ttl must be between 1 and 365"},344+ {"ttl text", []string{"sites", "*", "ttl=soon"}, "invalid: agent grant sites ttl must be between 1 and 365"},345+ {"unknown flag", []string{"sites", own, "public"}, "invalid: agent grant sites tag allows only ttl=<days> and encrypted"},346+ } {347+ t.Run(tc.name, func(t *testing.T) {348+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, tc.tag), "")349+ err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow)350+ if err == nil || !strings.HasPrefix(err.Error(), tc.want) {351+ t.Fatalf("want %q, got %v", tc.want, err)352+ }353+ })354+ }355+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, []string{"sites", own, "ttl=30", "encrypted"}, []string{"sites", own, "ttl=7"}, []string{"sites", "*"}), "")356+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {357+ t.Fatalf("valid sites grant rejected: %v", err)358+ }359+ grant, err := community.ParseAgentGrant(e, agentNow)360+ if err != nil {361+ t.Fatal(err)362+ }363+ want := []community.AgentSite{{Label: own, TTLDays: 30, Encrypted: true}, {Label: "*"}}364+ if len(grant.Scope.Sites) != len(want) || grant.Scope.Sites[0] != want[0] || grant.Scope.Sites[1] != want[1] {365+ t.Fatalf("sites = %+v, want %+v", grant.Scope.Sites, want)366+ }367+ if !grant.UploadsEncrypted() || grant.UploadTTLDays() != 0 {368+ t.Fatalf("an entry without a ttl should leave uploads unlimited: ttl=%d encrypted=%v", grant.UploadTTLDays(), grant.UploadsEncrypted())369+ }370+}