1package gates23import (4 "context"5 "encoding/base64"6 "fmt"7 "regexp"89 "github.com/FelineStateMachine/tinyrelay/internal/event"10 "github.com/FelineStateMachine/tinyrelay/internal/relay"11)1213var marmotHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)14var marmotID = regexp.MustCompile(`^0x[0-9a-f]{4}$`)15var marmotVersion = regexp.MustCompile(`^1\.0$`)16var marmotHex = regexp.MustCompile(`^[0-9a-f]+$`)1718// MarmotShape validates relay-visible MLS envelope structure without decoding MLS.19func MarmotShape(e event.Event) error {20 if e.Kind == event.KIND_MARMOT_GROUP {21 return groupShape(e)22 }23 if e.Kind == event.KIND_MARMOT_KEY_PACKAGE {24 return keyPackageShape(e)25 }26 return nil27}2829func groupShape(e event.Event) error {30 if !singletonTag(e, "h", marmotHex64) {31 return fmt.Errorf("invalid: kind 445 needs one lowercase 32-byte h tag")32 }33 b, err := base64.StdEncoding.Strict().DecodeString(e.Content)34 if err != nil || len(b) < 28 {35 return fmt.Errorf("invalid: kind 445 content must be padded base64 containing a nonce and authentication tag")36 }37 for _, tag := range e.Tags {38 if len(tag) == 0 || tag[0] != "h" && !(tag[0] == "expiration" && len(tag) == 2 && digits(tag[1])) {39 return fmt.Errorf("invalid: kind 445 has an unsupported tag")40 }41 }42 if countTag(e, "expiration") > 1 {43 return fmt.Errorf("invalid: kind 445 has repeated expiration")44 }45 return nil46}4748func keyPackageShape(e event.Event) error {49 if !singletonTag(e, "d", marmotHex64) || !singletonTag(e, "mls_protocol_version", marmotVersion) || !singletonTag(e, "i", marmotHex) {50 return fmt.Errorf("invalid: kind 30443 has a malformed singleton tag")51 }52 for _, name := range []string{"mls_ciphersuite", "mls_extensions", "mls_proposals", "app_components"} {53 if !idList(e, name) {54 return fmt.Errorf("invalid: kind 30443 has a malformed id-list tag")55 }56 }57 for _, tag := range e.Tags {58 if len(tag) > 0 && tag[0] == "app_components" {59 found := false60 for _, id := range tag[1:] {61 if id == "0x8009" {62 found = true63 }64 }65 if !found {66 return fmt.Errorf("invalid: kind 30443 must advertise account identity proof")67 }68 }69 }70 decoded, err := base64.StdEncoding.Strict().DecodeString(e.Content)71 if err != nil || len(decoded) == 0 {72 return fmt.Errorf("invalid: kind 30443 content must be padded base64")73 }74 allowed := map[string]bool{"d": true, "mls_protocol_version": true, "i": true, "mls_ciphersuite": true, "mls_extensions": true, "mls_proposals": true, "app_components": true}75 for _, tag := range e.Tags {76 if len(tag) == 0 || !allowed[tag[0]] {77 return fmt.Errorf("invalid: kind 30443 has an unsupported tag")78 }79 }80 return nil81}8283// Principal returns the authenticated account whose write policy applies to a group envelope.84func (g *Gate) Principal(ctx context.Context, e event.Event, s relay.Session) (string, error) {85 if e.Kind != event.KIND_MARMOT_GROUP {86 return e.PubKey, nil87 }88 p := g.cfg.Policy()89 if p.Writes == "open" {90 if len(s.PubKeys) > 0 {91 return s.PubKeys[0], nil92 }93 return e.PubKey, nil94 }95 for _, key := range s.PubKeys {96 a := accessFor(ctx, g.cfg.Community, p, relay.Session{PubKeys: []string{key}})97 if a.Owner || a.Member {98 return key, nil99 }100 }101 return "", fmt.Errorf("auth-required: Marmot group envelopes need an authenticated account allowed to write here")102}103104func singletonTag(e event.Event, name string, valid *regexp.Regexp) bool {105 found := 0106 for _, tag := range e.Tags {107 if len(tag) > 0 && tag[0] == name {108 found++109 if len(tag) != 2 || !valid.MatchString(tag[1]) {110 return false111 }112 }113 }114 return found == 1115}116func idList(e event.Event, name string) bool {117 found := 0118 for _, tag := range e.Tags {119 if len(tag) == 0 || tag[0] != name {120 continue121 }122 found++123 if len(tag) < 2 {124 return false125 }126 seen := map[string]bool{}127 for _, id := range tag[1:] {128 if !marmotID.MatchString(id) || seen[id] {129 return false130 }131 seen[id] = true132 }133 }134 return found == 1135}136func countTag(e event.Event, name string) int {137 n := 0138 for _, tag := range e.Tags {139 if tag[0] == name {140 n++141 }142 }143 return n144}145func digits(s string) bool {146 if s == "" {147 return false148 }149 for _, r := range s {150 if r < '0' || r > '9' {151 return false152 }153 }154 return true155}