6 changed files with 1014 additions and 202 deletions
@ -0,0 +1,5 @@
@@ -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 |
||||
@ -0,0 +1,75 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,191 @@
@@ -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 |
||||
} |
||||
Loading…
Reference in new issue