Implement NIP-98 authentication for HTTP requests, enhancing security for event export and import functionalities. Update server methods to validate authentication and permissions, and refactor event handling in the Svelte app to support new export and import features. Add UI components for exporting and importing events with appropriate permission checks.

This commit is contained in:
2025-10-08 20:06:58 +01:00
parent 332b9b05f7
commit 2bdc1b7bc0
6 changed files with 1028 additions and 216 deletions

View File

@@ -0,0 +1,5 @@
// Package httpauth provides helpers and encoders for nostr NIP-98 HTTP
// authentication header messages and a new JWT authentication message and
// delegation event kind 13004 that enables time limited expiring delegations of
// authentication (as with NIP-42 auth) for the HTTP API.
package httpauth

View File

@@ -0,0 +1,75 @@
package httpauth
import (
"encoding/base64"
"net/http"
"net/url"
"strings"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/interfaces/signer"
)
const (
HeaderKey = "Authorization"
NIP98Prefix = "Nostr"
)
// MakeNIP98Event creates a new NIP-98 event. If expiry is given, method is
// ignored; otherwise either option is the same.
func MakeNIP98Event(u, method, hash string, expiry int64) (ev *event.E) {
var t []*tag.T
t = append(t, tag.NewFromAny("u", u))
if expiry > 0 {
t = append(
t,
tag.NewFromAny("expiration", timestamp.FromUnix(expiry).String()),
)
} else {
t = append(
t,
tag.NewFromAny("method", strings.ToUpper(method)),
)
}
if hash != "" {
t = append(t, tag.NewFromAny("payload", hash))
}
ev = &event.E{
CreatedAt: timestamp.Now().V,
Kind: kind.HTTPAuth.K,
Tags: tag.NewS(t...),
}
return
}
func CreateNIP98Blob(
ur, method, hash string, expiry int64, sign signer.I,
) (blob string, err error) {
ev := MakeNIP98Event(ur, method, hash, expiry)
if err = ev.Sign(sign); chk.E(err) {
return
}
// log.T.F("nip-98 http auth event:\n%s\n", ev.SerializeIndented())
blob = base64.URLEncoding.EncodeToString(ev.Serialize())
return
}
// AddNIP98Header creates a NIP-98 http auth event and adds the standard header to a provided
// http.Request.
func AddNIP98Header(
r *http.Request, ur *url.URL, method, hash string,
sign signer.I, expiry int64,
) (err error) {
var b64 string
if b64, err = CreateNIP98Blob(
ur.String(), method, hash, expiry, sign,
); chk.E(err) {
return
}
r.Header.Add(HeaderKey, "Nostr "+b64)
return
}

View File

@@ -0,0 +1,191 @@
package httpauth
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/ints"
"next.orly.dev/pkg/encoders/kind"
)
var ErrMissingKey = fmt.Errorf(
"'%s' key missing from request header", HeaderKey,
)
// CheckAuth verifies a received http.Request has got a valid authentication
// event in it, with an optional specification for tolerance of before and
// after, and provides the public key that should be verified to be authorized
// to access the resource associated with the request.
func CheckAuth(r *http.Request, tolerance ...time.Duration) (
valid bool,
pubkey []byte, err error,
) {
val := r.Header.Get(HeaderKey)
if val == "" {
err = ErrMissingKey
valid = true
return
}
if len(tolerance) == 0 {
tolerance = append(tolerance, time.Minute)
}
// log.I.S(tolerance)
if tolerance[0] == 0 {
tolerance[0] = time.Minute
}
tolerate := int64(tolerance[0] / time.Second)
log.T.C(func() string { return fmt.Sprintf("validating auth '%s'", val) })
switch {
case strings.HasPrefix(val, NIP98Prefix):
split := strings.Split(val, " ")
if len(split) == 1 {
err = errorf.E(
"missing nip-98 auth event from '%s' http header key: '%s'",
HeaderKey, val,
)
}
if len(split) > 2 {
err = errorf.E(
"extraneous content after second field space separated: %s",
val,
)
return
}
var evb []byte
if evb, err = base64.URLEncoding.DecodeString(split[1]); chk.E(err) {
return
}
ev := event.New()
var rem []byte
if rem, err = ev.Unmarshal(evb); chk.E(err) {
return
}
if len(rem) > 0 {
err = errorf.E("rem", rem)
return
}
// log.T.F("received http auth event:\n%s\n", ev.SerializeIndented())
// The kind MUST be 27235.
if ev.Kind != kind.HTTPAuth.K {
err = errorf.E(
"invalid kind %d %s in nip-98 http auth event, require %d %s",
ev.Kind, kind.GetString(ev.Kind), kind.HTTPAuth.K,
kind.HTTPAuth.Name(),
)
return
}
// if there is an expiration timestamp, check it supersedes the
// created_at for validity.
exp := ev.Tags.GetAll([]byte("expiration"))
if len(exp) > 1 {
err = errorf.E(
"more than one \"expiration\" tag found",
)
return
}
var expiring bool
if len(exp) == 1 {
ex := ints.New(0)
exp1 := exp[0]
if rem, err = ex.Unmarshal(exp1.Value()); chk.E(err) {
return
}
tn := time.Now().Unix()
if tn > ex.Int64()+tolerate {
err = errorf.E(
"HTTP auth event is expired %d time now %d",
tn, ex.Int64()+tolerate,
)
return
}
expiring = true
} else {
// The created_at timestamp MUST be within a reasonable time window
// (suggestion 60 seconds)
ts := ev.CreatedAt
tn := time.Now().Unix()
if ts < tn-tolerate || ts > tn+tolerate {
err = errorf.E(
"timestamp %d is more than %d seconds divergent from now %d",
ts, tolerate, tn,
)
return
}
}
ut := ev.Tags.GetAll([]byte("u"))
if len(ut) > 1 {
err = errorf.E(
"more than one \"u\" tag found",
)
return
}
// The u tag MUST be exactly the same as the absolute request URL
// (including query parameters).
proto := r.URL.Scheme
// if this came through a proxy, we need to get the protocol to match
// the event
if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
proto = p
}
if proto == "" {
proto = "http"
}
fullUrl := proto + "://" + r.Host + r.URL.RequestURI()
evUrl := string(ut[0].Value())
log.T.F("full URL: %s event u tag value: %s", fullUrl, evUrl)
if expiring {
// if it is expiring, the URL only needs to be the same prefix to
// allow its use with multiple endpoints.
if !strings.HasPrefix(fullUrl, evUrl) {
err = errorf.E(
"request URL %s is not prefixed with the u tag URL %s",
fullUrl, evUrl,
)
return
}
} else if fullUrl != evUrl {
err = errorf.E(
"request has URL %s but signed nip-98 event has url %s",
fullUrl, string(ut[0].Value()),
)
return
}
if !expiring {
// The method tag MUST be the same HTTP method used for the
// requested resource.
mt := ev.Tags.GetAll([]byte("method"))
if len(mt) != 1 {
err = errorf.E(
"more than one \"method\" tag found",
)
return
}
if !strings.EqualFold(string(mt[0].Value()), r.Method) {
err = errorf.E(
"request has method %s but event has method %s",
string(mt[0].Value()), r.Method,
)
return
}
}
if valid, err = ev.Verify(); chk.E(err) {
return
}
if !valid {
return
}
pubkey = ev.Pubkey
default:
err = errorf.E("invalid '%s' value: '%s'", HeaderKey, val)
return
}
return
}