working jwt token with expiry on event upload

This commit is contained in:
2025-03-21 14:49:51 -01:06
parent 353d53270c
commit d37cf59a4e
16 changed files with 378 additions and 31 deletions

2
.gitignore vendored
View File

@@ -25,7 +25,7 @@ node_modules/**
**/node_modules
**/node_modules/
**/node_modules/**
/test*
# and others
/go.work.sum
/secp256k1/

View File

@@ -21,9 +21,9 @@ import (
)
const (
issuer = "NOSTR_PUBLIC_KEY"
secEnv = "NOSTR_SECRET_KEY"
jwtSecEnv = "NOSTR_JWT_SECRET"
jwtIssuerEnv = "NOSTR_PUBLIC_KEY"
secEnv = "NOSTR_SECRET_KEY"
jwtSecEnv = "NOSTR_JWT_SECRET"
)
var userAgent = fmt.Sprintf("nostrjwt/%s", realy_lol.Version)
@@ -67,7 +67,7 @@ nostrjwt bearer <request URL> [<optional expiry in 0h0m0s format for JWT token>]
expiry sets an amount of time after the current moment that the token
will expire
`, kind.JWTBinding.K, jwtSecEnv, issuer, jwtSecEnv)
`, kind.JWTBinding.K, jwtSecEnv, jwtIssuerEnv, jwtSecEnv)
os.Exit(0)
}
var err error
@@ -95,7 +95,7 @@ nostrjwt bearer <request URL> [<optional expiry in 0h0m0s format for JWT token>]
}
fmt.Printf("%s\n%s\n", pemSec, pemPub)
fmt.Printf("export %s=%s\n", jwtSecEnv, x509sec)
fmt.Printf("export %s=%s\n\n", issuer, pub)
fmt.Printf("export %s=%s\n\n", jwtIssuerEnv, pub)
var ev event.T
httpauth.MakeJWTEvent(string(x509pub))
ev.Tags = tags.New(tag.New([]byte("J"), x509pub, []byte("ES256")))
@@ -104,7 +104,7 @@ nostrjwt bearer <request URL> [<optional expiry in 0h0m0s format for JWT token>]
if err = ev.Sign(sign); chk.E(err) {
fail(err.Error())
}
fmt.Printf("%s\n", ev.Serialize())
fmt.Printf("Nostr %s\n", ev.Serialize())
case "bearer":
// check args
@@ -118,6 +118,10 @@ nostrjwt bearer <request URL> [<optional expiry in 0h0m0s format for JWT token>]
if len(jwtSec) == 0 {
fail("no key found in environment variable %s", jwtSecEnv)
}
jwtIss := os.Getenv(jwtIssuerEnv)
if len(jwtIss) == 0 {
fail("no pubkey found in environment variable %s", jwtIssuerEnv)
}
if jskb, err = base64.URLEncoding.DecodeString(jwtSec); chk.E(err) {
fail(err.Error())
}
@@ -126,13 +130,14 @@ nostrjwt bearer <request URL> [<optional expiry in 0h0m0s format for JWT token>]
fail(err.Error())
}
var tok []byte
log.I.S(os.Args)
// generate claim
if len(os.Args) < 5 {
if tok, err = httpauth.GenerateJWTClaims(os.Args[2], os.Args[3]); chk.E(err) {
if len(os.Args) == 3 {
if tok, err = httpauth.GenerateJWTClaims(os.Args[2], jwtIss); chk.E(err) {
fail(err.Error())
}
} else if len(os.Args) > 4 {
if tok, err = httpauth.GenerateJWTClaims(os.Args[2], os.Args[3], os.Args[4]); chk.E(err) {
} else if len(os.Args) == 4 {
if tok, err = httpauth.GenerateJWTClaims(os.Args[2], jwtIss, os.Args[3]); chk.E(err) {
fail(err.Error())
}
}
@@ -141,7 +146,7 @@ nostrjwt bearer <request URL> [<optional expiry in 0h0m0s format for JWT token>]
if signed, err = httpauth.SignJWTtoken(tok, sec); chk.E(err) {
fail(err.Error())
}
fmt.Printf("%s", signed)
fmt.Printf("Bearer %s\n", signed)
}
}
}

View File

@@ -9,7 +9,7 @@ import (
)
func MonitorResources(c context.T) {
tick := time.NewTicker(time.Minute)
tick := time.NewTicker(time.Minute * 15)
log.I.Ln("running process", os.Args[0], os.Getpid())
// memStats := &runtime.MemStats{}
for {

View File

@@ -2,6 +2,7 @@ package event
import (
"bytes"
"encoding/json"
"io"
"realy.lol/ec/schnorr"
@@ -98,6 +99,24 @@ func (ev *T) marshalWithWhitespace(dst []byte, on bool) (b []byte) {
func Marshal(ev *T, dst []byte) (b []byte) { return ev.Marshal(dst) }
func (ev *T) Unmarshal(b []byte) (r []byte, err error) {
// this parser does not cope with whitespaces in valid places in json, so we
// scan first for linebreaks, as these indicate that it is probably not gona work and fall back to json.Unmarshal
for _, v := range b {
if v == '\n' {
// revert to json.Unmarshal
var j J
if err = json.Unmarshal(b, &j); chk.E(err) {
return
}
var e *T
if e, err = j.ToEvent(); chk.E(err) {
return
}
*ev = *e
return
}
}
key := make([]byte, 0, 9)
r = b
for ; len(r) > 0; r = r[1:] {

View File

@@ -9,6 +9,8 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -83,7 +85,7 @@ func GenerateJWTKeys() (x509sec, x509pub, pemSec, pemPub []byte, sk *ecdsa.Priva
return
}
func GenerateJWTClaims(issuer, ur string,
func GenerateJWTClaims(ur, issuer string,
exp ...string) (tok []byte, err error) {
// generate claim
claim := &JWT{
@@ -142,8 +144,13 @@ func GenerateAndSignJWTtoken(issuer, ur, exp, sec string) (bearer string, err er
// that matches the signature on the JWT token.
type VerifyJWTFunc func(npub string) (jwtPub string, pk []byte, err error)
// VerifyJWTtoken checks that the claims and signature on a JWT token are valid,
// and returns the public key to check the signer matches with a nostr npub
// issuer.
//
// If there is an expiry, it only checks that the token's URL is the same as the
// prefix of the URL being verified for.
func VerifyJWTtoken(entry, URL string, vfn VerifyJWTFunc) (pk []byte, valid bool, err error) {
var token *jwt.Token
if token, err = jwt.Parse(entry, func(token *jwt.Token) (ifc interface{}, err error) {
var iss string
@@ -162,12 +169,6 @@ func VerifyJWTtoken(entry, URL string, vfn VerifyJWTFunc) (pk []byte, valid bool
if jpk, err = x509.ParsePKIXPublicKey(pkb); chk.E(err) {
return
}
ifc = jpk
var sub string
if sub, err = token.Claims.GetSubject(); sub != URL {
err = errors.Wrap(jwt.ErrTokenInvalidClaims, "subject doesn't match expected URL")
return
}
now := time.Now().Unix()
var exp *jwt.NumericDate
if exp, err = token.Claims.GetExpirationTime(); chk.E(err) {
@@ -192,6 +193,25 @@ func VerifyJWTtoken(entry, URL string, vfn VerifyJWTFunc) (pk []byte, valid bool
}
}
var sub string
if sub, err = token.Claims.GetSubject(); chk.E(err) {
err = errors.Wrap(jwt.ErrTokenInvalidClaims, err.Error())
return
}
// when expiry is present the URL only needs to match on a prefix (already checked)
if exp != nil {
if !strings.HasPrefix(URL, sub) {
log.I.S(URL, sub)
err = errors.Wrap(jwt.ErrTokenInvalidClaims,
fmt.Sprintf("subject doesn't match expected URL prefix for an expiring token %s != %s", sub, URL))
return
}
} else if sub != URL {
err = errors.Wrap(jwt.ErrTokenInvalidClaims, "subject doesn't match expected URL")
return
}
ifc = jpk
return
}, jwt.WithoutClaimsValidation()); chk.E(err) {
return

View File

@@ -37,7 +37,7 @@ func TestSignJWTtoken_VerifyJWTtoken(t *testing.T) {
}
spub := base64.URLEncoding.EncodeToString(spkb)
var tok []byte
if tok, err = GenerateJWTClaims(pub, "https://example.com", "1h"); chk.E(err) {
if tok, err = GenerateJWTClaims("https://example.com", pub, "1h"); chk.E(err) {
t.Fatal(err)
}
var entry string
@@ -47,7 +47,7 @@ func TestSignJWTtoken_VerifyJWTtoken(t *testing.T) {
vfn := func(npub string) (jwtPub string, pk []byte, err error) {
// pubkey in token claims must match what we just put in it
if npub != pub {
err = fmt.Errorf("invalid jwt token npub")
err = fmt.Errorf("invalid jwt token npub, got %s expected %s", npub, pub)
return
}
pk = sign.Pub()

View File

@@ -18,12 +18,12 @@ import (
// A VerifyJWTFunc should be provided in order to search the event store for a
// kind 13004 with a JWT signer pubkey that is granted authority for the request.
func CheckAuth(r *http.Request, vfn VerifyJWTFunc) (valid bool, pubkey []byte, err error) {
log.I.F("validating auth %v", vfn)
val := r.Header.Get(HeaderKey)
if val == "" {
err = errorf.E("'%s' key missing from request header", HeaderKey)
return
}
log.I.F("validating auth '%s'", val)
switch {
case strings.HasPrefix(val, NIP98Prefix):
split := strings.Split(val, " ")

View File

@@ -2,6 +2,7 @@ package ints
import (
_ "embed"
"io"
)
// run this to regenerate (pointlessly) the base 10 array of 4 places per entry
@@ -87,6 +88,17 @@ func (n *T) Unmarshal(b []byte) (r []byte, err error) {
n.N = 0
return
}
// skip non-number characters
for i, v := range b {
if v >= '0' && v <= '9' {
b = b[i:]
break
}
}
if len(b) == 0 {
err = io.EOF
return
}
// count the digits
for ; sLen < len(b) && b[sLen] >= zero && b[sLen] <= nine && b[sLen] != ','; sLen++ {
}

View File

@@ -67,5 +67,6 @@ func (s *Server) addEvent(c context.T, rl relay.I, ev *event.T,
}
s.listeners.NotifyListeners(authRequired, ev)
accepted = true
log.I.F("event id %0x stored", ev.ID)
return
}

View File

@@ -40,6 +40,7 @@ func (s *Server) JWTVerifyFunc(npub string) (jwtPub string, pk []byte, err error
npub, ev.SerializeIndented())
return
}
pk = ev.PubKey
jwtPub = string(jtag.F()[0].Value())
return
}

218
realy/http-event.go Normal file
View File

@@ -0,0 +1,218 @@
package realy
import (
"bytes"
"fmt"
"net/http"
"github.com/danielgtaylor/huma/v2"
"realy.lol/context"
"realy.lol/event"
"realy.lol/filter"
"realy.lol/hex"
"realy.lol/httpauth"
"realy.lol/ints"
"realy.lol/kind"
"realy.lol/relay"
"realy.lol/sha256"
"realy.lol/tag"
)
type EventPost struct{ *Server }
func NewEventPost(s *Server) (ep *EventPost) {
return &EventPost{Server: s}
}
type EventInput struct {
RawBody []byte
Auth string `header:"Authorization"`
}
type EventOutput struct{ Body string }
func (ep *EventPost) RegisterEventPost(api huma.API) {
name := "Event"
description := "Submit an event"
path := "/event"
scopes := []string{"write"}
method := http.MethodPost
huma.Register(api, huma.Operation{
OperationID: name,
Summary: name,
Path: path,
Method: method,
Tags: []string{"events"},
Description: generateDescription(description, scopes),
Security: []map[string][]string{{"auth": scopes}},
}, func(ctx context.T, input *EventInput) (wgh *EventOutput, err error) {
log.I.S(ctx)
r := ctx.Value("http-request").(*http.Request)
w := ctx.Value("http-response").(http.ResponseWriter)
rr := GetRemoteFromReq(r)
log.I.S(r.RemoteAddr, rr)
ev := &event.T{}
if _, err = ev.Unmarshal(input.RawBody); chk.E(err) {
err = huma.Error406NotAcceptable(err.Error())
return
}
var ok bool
s := ep.Server
sto := s.relay.Storage()
advancedDeleter, _ := sto.(relay.AdvancedDeleter)
var valid bool
var pubkey []byte
if valid, pubkey, err = httpauth.CheckAuth(r, s.JWTVerifyFunc); chk.E(err) {
return
}
if !valid {
// pubkey = ev.PubKey
err = huma.Error401Unauthorized(
fmt.Sprintf("invalid: %s", err.Error()))
return
}
c := context.Bg()
accept, notice, after := s.relay.AcceptEvent(c, ev, r, rr, pubkey)
if !accept {
err = huma.Error401Unauthorized(notice)
return
}
if !bytes.Equal(ev.GetIDBytes(), ev.ID) {
err = huma.Error400BadRequest("event id is computed incorrectly")
return
}
if ok, err = ev.Verify(); chk.T(err) {
err = huma.Error400BadRequest("failed to verify signature")
return
} else if !ok {
err = huma.Error400BadRequest("signature is invalid")
return
}
storage := s.relay.Storage()
if storage == nil {
panic("no event store has been set to store event")
}
if ev.Kind.K == kind.Deletion.K {
log.I.F("delete event\n%s", ev.Serialize())
for _, t := range ev.Tags.Value() {
var res []*event.T
if t.Len() >= 2 {
switch {
case bytes.Equal(t.Key(), []byte("e")):
evId := make([]byte, sha256.Size)
if _, err = hex.DecBytes(evId, t.Value()); chk.E(err) {
continue
}
res, err = storage.QueryEvents(c, &filter.T{IDs: tag.New(evId)})
if err != nil {
err = huma.Error500InternalServerError(err.Error())
return
}
for i := range res {
if res[i].Kind.Equal(kind.Deletion) {
err = huma.Error409Conflict("not processing or storing delete event containing delete event references")
}
if !bytes.Equal(res[i].PubKey, ev.PubKey) {
err = huma.Error409Conflict("cannot delete other users' events (delete by e tag)")
return
}
}
case bytes.Equal(t.Key(), []byte("a")):
split := bytes.Split(t.Value(), []byte{':'})
if len(split) != 3 {
continue
}
var pk []byte
if pk, err = hex.DecAppend(nil, split[1]); chk.E(err) {
err = huma.Error400BadRequest(fmt.Sprintf("delete event a tag pubkey value invalid: %s",
t.Value()))
return
}
kin := ints.New(uint16(0))
if _, err = kin.Unmarshal(split[0]); chk.E(err) {
err = huma.Error400BadRequest(fmt.Sprintf("delete event a tag kind value invalid: %s",
t.Value()))
return
}
kk := kind.New(kin.Uint16())
if kk.Equal(kind.Deletion) {
err = huma.Error403Forbidden("delete event kind may not be deleted")
return
}
if !kk.IsParameterizedReplaceable() {
err = huma.Error403Forbidden("delete tags with a tags containing non-parameterized-replaceable events cannot be processed")
return
}
if !bytes.Equal(pk, ev.PubKey) {
log.I.S(pk, ev.PubKey, ev)
err = huma.Error403Forbidden("cannot delete other users' events (delete by a tag)")
return
}
f := filter.New()
f.Kinds.K = []*kind.T{kk}
f.Authors.Append(pk)
f.Tags.AppendTags(tag.New([]byte{'#', 'd'}, split[2]))
res, err = storage.QueryEvents(c, f)
if err != nil {
http.Error(w, err.Error(), ERR)
return
}
}
}
if len(res) < 1 {
continue
}
var resTmp []*event.T
for _, v := range res {
if ev.CreatedAt.U64() >= v.CreatedAt.U64() {
resTmp = append(resTmp, v)
}
}
res = resTmp
for _, target := range res {
if target.Kind.K == kind.Deletion.K {
err = huma.Error403Forbidden(fmt.Sprintf(
"cannot delete delete event %s", ev.ID))
return
}
if target.CreatedAt.Int() > ev.CreatedAt.Int() {
// todo: shouldn't this be an error?
log.I.F("not deleting\n%d%\nbecause delete event is older\n%d",
target.CreatedAt.Int(), ev.CreatedAt.Int())
continue
}
if !bytes.Equal(target.PubKey, ev.PubKey) {
err = huma.Error403Forbidden("only author can delete event")
return
}
if advancedDeleter != nil {
advancedDeleter.BeforeDelete(c, t.Value(), ev.PubKey)
}
if err = sto.DeleteEvent(c, target.EventID()); chk.T(err) {
err = huma.Error500InternalServerError(err.Error())
return
}
if advancedDeleter != nil {
advancedDeleter.AfterDelete(t.Value(), ev.PubKey)
}
}
res = nil
}
return
}
var reason []byte
ok, reason = s.addEvent(c, s.relay, ev, r, rr, pubkey)
// return the response whether true or false and any reason if false
if ok {
} else {
err = huma.Error500InternalServerError(string(reason))
}
if after != nil {
// do this in the background and let the http response close
go after()
}
wgh = &EventOutput{"event accepted"}
return
})
}

40
realy/huma.go Normal file
View File

@@ -0,0 +1,40 @@
package realy
import (
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
)
// ExposeMiddleware adds the http.Request and http.ResponseWriter to the context
// for the Operations handler.
func ExposeMiddleware(ctx huma.Context, next func(huma.Context)) {
// Unwrap the request and response objects.
r, w := humago.Unwrap(ctx)
ctx = huma.WithValue(ctx, "http-request", r)
ctx = huma.WithValue(ctx, "http-response", w)
next(ctx)
}
func NewHuma(router *ServeMux, name, version, description string) (api huma.API) {
apiConfig := huma.DefaultConfig(name, version)
apiConfig.Info.Description = description
// apiConfig.Security = []map[string][]string{{"auth": {}}}
// apiConfig.Components.SecuritySchemes = map[string]*huma.SecurityScheme{"auth": {Type: "http", Scheme: "apiKey"}} // apiKey todo:
apiConfig.DocsPath = "/api"
api = humago.New(router, apiConfig)
api.UseMiddleware(ExposeMiddleware)
return
}
func generateDescription(text string, scopes []string) string {
if len(scopes) == 0 {
return text
}
result := make([]string, 0)
for _, value := range scopes {
result = append(result, "`"+value+"`")
}
return text + "<br/><br/>**Scopes**<br/>" + strings.Join(result, ", ")
}

View File

@@ -45,8 +45,8 @@ func (s *Server) handleRelayInfo(w http.ResponseWriter, r *http.Request) {
sort.Sort(supportedNIPs)
log.T.Ln("supported NIPs", supportedNIPs)
info = &relayinfo.T{Name: s.relay.Name(),
Description: "relay powered by the realy framework",
Nips: supportedNIPs, Software: "https://realy.lol", Version: realy_lol.Version,
Description: realy_lol.Description,
Nips: supportedNIPs, Software: realy_lol.URL, Version: realy_lol.Version,
Limitation: relayinfo.Limits{
MaxLimit: s.maxLimit,
AuthRequired: s.authRequired,

21
realy/serveMux.go Normal file
View File

@@ -0,0 +1,21 @@
package realy
import "net/http"
type ServeMux struct {
*http.ServeMux
}
func NewServeMux() *ServeMux {
return &ServeMux{http.NewServeMux()}
}
func (c *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
return
}
c.ServeMux.ServeHTTP(w, r)
}

View File

@@ -11,9 +11,11 @@ import (
"sync"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/fasthttp/websocket"
"github.com/rs/cors"
realy_lol "realy.lol"
"realy.lol/context"
"realy.lol/realy/listeners"
"realy.lol/realy/options"
@@ -29,7 +31,7 @@ type Server struct {
clientsMu sync.Mutex
clients map[*websocket.Conn]struct{}
Addr string
mux *http.ServeMux
mux *ServeMux
httpServer *http.Server
authRequired bool
publicReadable bool
@@ -37,6 +39,7 @@ type Server struct {
admins []signer.I
owners [][]byte
listeners *listeners.T
huma.API
}
type ServerParams struct {
@@ -67,12 +70,13 @@ func NewServer(sp *ServerParams, opts ...options.O) (*Server, error) {
if err := sp.Rl.Init(); chk.T(err) {
return nil, fmt.Errorf("realy init: %w", err)
}
serveMux := NewServeMux()
srv := &Server{
Ctx: sp.Ctx,
Cancel: sp.Cancel,
relay: sp.Rl,
clients: make(map[*websocket.Conn]struct{}),
mux: http.NewServeMux(),
mux: serveMux,
options: op,
authRequired: authRequired,
publicReadable: sp.PublicReadable,
@@ -80,7 +84,9 @@ func NewServer(sp *ServerParams, opts ...options.O) (*Server, error) {
admins: sp.Admins,
owners: sp.Rl.Owners(),
listeners: listeners.New(),
API: NewHuma(serveMux, sp.Rl.Name(), realy_lol.Version, realy_lol.Description),
}
huma.AutoRegister(srv.API, NewEventPost(srv))
if inj, ok := sp.Rl.(relay.Injector); ok {
go func() {
for ev := range inj.InjectEvents() {
@@ -101,7 +107,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handleWebsocket(w, r)
return
}
s.HandleHTTP(w, r)
log.I.S(r.URL)
s.mux.ServeHTTP(w, r)
// s.HandleHTTP(w, r)
// s.mux.ServeHTTP(w, r)
}
@@ -150,7 +158,7 @@ func (s *Server) Shutdown() {
}
func (s *Server) Router() *http.ServeMux {
return s.mux
return s.mux.ServeMux
}
func fprintf(w io.Writer, format string, a ...any) { _, _ = fmt.Fprintf(w, format, a...) }

View File

@@ -91,7 +91,9 @@ func (t *T) Marshal(dst []byte) (b []byte) { return ints.New(t.U64()).Marshal(ds
func (t *T) Unmarshal(b []byte) (r []byte, err error) {
n := ints.New(0)
r, err = n.Unmarshal(b)
if r, err = n.Unmarshal(b); chk.E(err) {
return
}
*t = T{n.Int64()}
return
}