add expiration http auth, remove jwt
This commit is contained in:
80
cmd/nauth/main.go
Normal file
80
cmd/nauth/main.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"realy.lol/bech32encoding"
|
||||||
|
"realy.lol/httpauth"
|
||||||
|
"realy.lol/p256k"
|
||||||
|
"realy.lol/signer"
|
||||||
|
)
|
||||||
|
|
||||||
|
const secEnv = "NOSTR_SECRET_KEY"
|
||||||
|
|
||||||
|
func fail(format string, a ...any) {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// lol.SetLogLevel("trace")
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "help" {
|
||||||
|
fmt.Printf(`nauth help:
|
||||||
|
|
||||||
|
for generating extended expiration NIP-98 tokens:
|
||||||
|
|
||||||
|
nauth <url prefix> <duration in 0h0m0s format>
|
||||||
|
|
||||||
|
* NIP-98 secret will be expected in the environment variable "%s" - if absent, will not be added to the header. Endpoint is assumed to not require it if absent. An error will be returned if it was needed.
|
||||||
|
|
||||||
|
output will be rendered to stdout
|
||||||
|
|
||||||
|
`, secEnv)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fail(`error: nauth requires minimum 2 args: <url> <duration in 0h0m0s format>
|
||||||
|
|
||||||
|
signing nsec (in bech32 format) is expected to be found in %s environment variable.
|
||||||
|
|
||||||
|
use "help" to get usage information
|
||||||
|
`, secEnv)
|
||||||
|
}
|
||||||
|
ex, err := time.ParseDuration(os.Args[2])
|
||||||
|
if err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
}
|
||||||
|
var sign signer.I
|
||||||
|
if sign, err = GetNIP98Signer(); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
}
|
||||||
|
exp := time.Now().Add(ex).Unix()
|
||||||
|
ev := httpauth.MakeNIP98Event(os.Args[1], "", "", exp)
|
||||||
|
if err = ev.Sign(sign); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
}
|
||||||
|
log.T.F("nip-98 http auth event:\n%s\n", ev.SerializeIndented())
|
||||||
|
b64 := base64.URLEncoding.EncodeToString(ev.Serialize())
|
||||||
|
fmt.Println("Nostr " + b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNIP98Signer() (sign signer.I, err error) {
|
||||||
|
nsex := os.Getenv(secEnv)
|
||||||
|
var sk []byte
|
||||||
|
if len(nsex) == 0 {
|
||||||
|
err = errorf.E("no bech32 secret key found in environment variable %s", secEnv)
|
||||||
|
return
|
||||||
|
} else if sk, err = bech32encoding.NsecToBytes([]byte(nsex)); chk.E(err) {
|
||||||
|
err = errorf.E("failed to decode nsec: '%s'", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sign = &p256k.Signer{}
|
||||||
|
if err = sign.InitSec(sk); chk.E(err) {
|
||||||
|
err = errorf.E("failed to init signer: '%s'", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
12
cmd/nauth/util.go
Normal file
12
cmd/nauth/util.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"realy.lol/lol"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
log, chk, errorf = lol.Main.Log, lol.Main.Check, lol.Main.Errorf
|
||||||
|
equals = bytes.Equal
|
||||||
|
)
|
||||||
@@ -101,7 +101,7 @@ func Get(ur *url.URL, sign signer.I) (err error) {
|
|||||||
r.Header.Add("User-Agent", userAgent)
|
r.Header.Add("User-Agent", userAgent)
|
||||||
r.Header.Add("Accept", "application/nostr+json")
|
r.Header.Add("Accept", "application/nostr+json")
|
||||||
if sign != nil {
|
if sign != nil {
|
||||||
if err = httpauth.AddNIP98Header(r, ur, "GET", "", sign); chk.E(err) {
|
if err = httpauth.AddNIP98Header(r, ur, "GET", "", sign, 0); chk.E(err) {
|
||||||
fail(err.Error())
|
fail(err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ func Post(f string, ur *url.URL, sign signer.I) (err error) {
|
|||||||
r.Header.Add("User-Agent", userAgent)
|
r.Header.Add("User-Agent", userAgent)
|
||||||
r.Header.Add("Accept", "application/nostr+json")
|
r.Header.Add("Accept", "application/nostr+json")
|
||||||
if sign != nil {
|
if sign != nil {
|
||||||
if err = httpauth.AddNIP98Header(r, ur, "POST", h, sign); chk.E(err) {
|
if err = httpauth.AddNIP98Header(r, ur, "POST", h, sign, 0); chk.E(err) {
|
||||||
fail(err.Error())
|
fail(err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ If one of the checks was to fail the server SHOULD respond with a 401 Unauthoriz
|
|||||||
|
|
||||||
Servers MAY perform additional implementation-specific validation checks.
|
Servers MAY perform additional implementation-specific validation checks.
|
||||||
|
|
||||||
|
== Expiration Variant
|
||||||
|
|
||||||
|
For cases where an authorization is needed for a more extended duration, and primarily for working with standard HTTP REST tooling and low-spec browsers, a variant of the foregoing specification with the following changes:
|
||||||
|
|
||||||
|
1. The method tag is not present.
|
||||||
|
2. The URL is the prefix instead of the exact URL, and the verification ensures the request matches the same prefix.
|
||||||
|
3. There is an `expiration` tag, same as the link:https://github.com/nostr-protocol/nips/blob/8f676dc0a55e75564b54d96bcadf787b61654219/40.md[NIP-40] with the expiration as a unix timestamp encoded as the string in the second, value field, and to be valid must be in the future when the server receives it.
|
||||||
|
|
||||||
== Request Flow
|
== Request Flow
|
||||||
|
|
||||||
Using the `Authorization` HTTP header, the `kind 27235` event MUST be `base64` encoded and use the Authorization scheme `Nostr`
|
Using the `Authorization` HTTP header, the `kind 27235` event MUST be `base64` encoded and use the Authorization scheme `Nostr`
|
||||||
|
|||||||
221
httpauth/jwt.go
221
httpauth/jwt.go
@@ -1,221 +0,0 @@
|
|||||||
package httpauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
|
|
||||||
"realy.lol/event"
|
|
||||||
"realy.lol/kind"
|
|
||||||
"realy.lol/tag"
|
|
||||||
"realy.lol/tags"
|
|
||||||
"realy.lol/timestamp"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
MaxSkew = 15
|
|
||||||
JWTPrefix = "Bearer"
|
|
||||||
PEMSecretLabel = "EC PRIVATE KEY"
|
|
||||||
PEMPublicLabel = "EC PUBLIC KEY"
|
|
||||||
DefaultAlg = "ES256"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MakeJWTEvent(jpk string) (ev *event.T) {
|
|
||||||
ev = &event.T{
|
|
||||||
CreatedAt: timestamp.Now(),
|
|
||||||
Kind: kind.JWTBinding,
|
|
||||||
Tags: tags.New(tag.New("J", jpk)),
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type JWT struct {
|
|
||||||
Issuer string `json:"iss"`
|
|
||||||
Subject string `json:"sub"`
|
|
||||||
Algorithm string `json:"alg"`
|
|
||||||
IssuedAt int64 `json:"iat"`
|
|
||||||
ExpirationTime int64 `json:"exp,omitempty"`
|
|
||||||
NotBefore int64 `json:"nbf,omitempty"`
|
|
||||||
Audience string `json:"aud,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateJWTKeys() (x509sec, x509pub, pemSec, pemPub []byte, sk *ecdsa.PrivateKey, pk ecdsa.PublicKey, err error) {
|
|
||||||
if sk, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pk = sk.PublicKey
|
|
||||||
var pkb []byte
|
|
||||||
if pkb, err = x509.MarshalPKIXPublicKey(sk.Public()); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
x509pub = make([]byte, len(pkb)*8/6+3)
|
|
||||||
base64.URLEncoding.Encode(x509pub, pkb)
|
|
||||||
|
|
||||||
var skb []byte
|
|
||||||
if skb, err = x509.MarshalECPrivateKey(sk); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
x509sec = make([]byte, len(skb)*8/6+3)
|
|
||||||
base64.URLEncoding.Encode(x509sec, skb)
|
|
||||||
|
|
||||||
bufS := new(bytes.Buffer)
|
|
||||||
if err = pem.Encode(bufS, &pem.Block{PEMSecretLabel,
|
|
||||||
nil, skb}); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pemSec = bufS.Bytes()
|
|
||||||
|
|
||||||
bufP := new(bytes.Buffer)
|
|
||||||
if err = pem.Encode(bufP, &pem.Block{PEMPublicLabel,
|
|
||||||
nil, pkb}); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pemPub = bufP.Bytes()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateJWTClaims(ur, issuer string,
|
|
||||||
exp ...string) (tok []byte, err error) {
|
|
||||||
// generate claim
|
|
||||||
claim := &JWT{
|
|
||||||
Issuer: issuer,
|
|
||||||
Subject: ur,
|
|
||||||
Algorithm: DefaultAlg,
|
|
||||||
IssuedAt: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
if len(exp) > 0 {
|
|
||||||
// parse duration
|
|
||||||
var dur time.Duration
|
|
||||||
if dur, err = time.ParseDuration(exp[0]); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
claim.ExpirationTime = claim.IssuedAt + int64(dur/time.Second)
|
|
||||||
}
|
|
||||||
if tok, err = json.Marshal(claim); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func SignJWTtoken(tok []byte, sec *ecdsa.PrivateKey) (bearer string, err error) {
|
|
||||||
var claims jwt.MapClaims
|
|
||||||
if err = json.Unmarshal(tok, &claims); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alg := jwt.GetSigningMethod(claims["alg"].(string))
|
|
||||||
token := jwt.NewWithClaims(alg, claims)
|
|
||||||
if bearer, err = token.SignedString(sec); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAndSignJWTtoken is a helper to do all the steps above in one, based
|
|
||||||
// on having a base64 encoded x509 secret key provided
|
|
||||||
func GenerateAndSignJWTtoken(issuer, ur, exp, sec string) (bearer string, err error) {
|
|
||||||
var t []byte
|
|
||||||
if t, err = GenerateJWTClaims(issuer, ur, exp); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var jskb []byte
|
|
||||||
if jskb, err = base64.URLEncoding.DecodeString(sec); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var sk *ecdsa.PrivateKey
|
|
||||||
if sk, err = x509.ParseECPrivateKey(jskb); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return SignJWTtoken(t, sk)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyJWTFunc ostensibly should be a function that queries the event store
|
|
||||||
// for a kind 13004 containing a J tag with a x509 encoded pubkey in base64-URL
|
|
||||||
// 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
|
|
||||||
if iss, err = token.Claims.GetIssuer(); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var jwtPub string
|
|
||||||
if jwtPub, pk, err = vfn(iss); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var pkb []byte
|
|
||||||
if pkb, err = base64.URLEncoding.DecodeString(jwtPub); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var jpk any
|
|
||||||
if jpk, err = x509.ParsePKIXPublicKey(pkb); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().Unix()
|
|
||||||
var exp *jwt.NumericDate
|
|
||||||
if exp, err = token.Claims.GetExpirationTime(); chk.E(err) {
|
|
||||||
}
|
|
||||||
if exp != nil {
|
|
||||||
cmp := now - exp.Unix()
|
|
||||||
if cmp > MaxSkew {
|
|
||||||
err = errors.Wrapf(jwt.ErrTokenInvalidClaims,
|
|
||||||
"token is expired, %ds since expiry %d, time now %d, max allowed %d", cmp, exp.Unix(), now, MaxSkew)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var iat *jwt.NumericDate
|
|
||||||
if iat, err = token.Claims.GetIssuedAt(); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cmp := time.Now().Unix() - iat.Unix()
|
|
||||||
if cmp > 15 || cmp < -15 {
|
|
||||||
err = errors.Wrapf(jwt.ErrTokenInvalidClaims,
|
|
||||||
"issued at is more than %d seconds skewed", cmp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
valid = token.Valid
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package httpauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (j *JWT) GetExpirationTime() (exp *jwt.NumericDate, err error) {
|
|
||||||
exp = jwt.NewNumericDate(time.Unix(j.ExpirationTime, 0))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWT) GetIssuedAt() (iat *jwt.NumericDate, err error) {
|
|
||||||
iat = jwt.NewNumericDate(time.Unix(j.IssuedAt, 0))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWT) GetNotBefore() (nbf *jwt.NumericDate, err error) {
|
|
||||||
nbf = jwt.NewNumericDate(time.Unix(j.NotBefore, 0))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWT) GetIssuer() (iss string, err error) {
|
|
||||||
iss = j.Issuer
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWT) GetSubject() (sub string, err error) {
|
|
||||||
sub = j.Subject
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWT) GetAudience() (aud jwt.ClaimStrings, err error) {
|
|
||||||
aud = jwt.ClaimStrings{j.Audience}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *JWT) Validate() (err error) {
|
|
||||||
log.I.S("validate")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package httpauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"realy.lol/p256k"
|
|
||||||
)
|
|
||||||
|
|
||||||
const jwtSecret = "MHcCAQEEIDlyFWD0KouB4n7aTPqlpNkoRTnuy7gMyY-YJusMsl0boAoGCCqGSM49AwEHoUQDQgAEDkH_rMzfr1LIHqnoFXyIYuz7dIYkg4qonbQhjeR0N_6CXpX2MqVHRLz9sx2EyXZZKPsFFbE_KJPczKu6qcIsRA=="
|
|
||||||
|
|
||||||
const URL = "https://example.com"
|
|
||||||
|
|
||||||
func TestSignJWTtoken_VerifyJWTtoken(t *testing.T) {
|
|
||||||
sign := &p256k.Signer{}
|
|
||||||
var err error
|
|
||||||
if err = sign.Generate(); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
pub := fmt.Sprintf("%0x", sign.Pub())
|
|
||||||
var jskb []byte
|
|
||||||
if jskb, err = base64.URLEncoding.DecodeString(jwtSecret); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var sec *ecdsa.PrivateKey
|
|
||||||
if sec, err = x509.ParseECPrivateKey(jskb); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
spk := &sec.PublicKey
|
|
||||||
var spkb []byte
|
|
||||||
if spkb, err = x509.MarshalPKIXPublicKey(spk); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
spub := base64.URLEncoding.EncodeToString(spkb)
|
|
||||||
var tok []byte
|
|
||||||
if tok, err = GenerateJWTClaims("https://example.com", pub, "1h"); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var entry string
|
|
||||||
if entry, err = SignJWTtoken(tok, sec); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
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, got %s expected %s", npub, pub)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pk = sign.Pub()
|
|
||||||
// we pretend that we found the 13004 event with the key if the above passed.
|
|
||||||
jwtPub = spub
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var valid bool
|
|
||||||
var pk []byte
|
|
||||||
if pk, valid, err = VerifyJWTtoken(entry, URL, vfn); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(pk, sign.Pub()) {
|
|
||||||
t.Fatalf("invalid npub, got %0x, expected %0x", pk, sign.Pub())
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
log.I.S(valid, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMakeJWTEvent(t *testing.T) {
|
|
||||||
var err error
|
|
||||||
sign := &p256k.Signer{}
|
|
||||||
if err = sign.Generate(); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var jskb []byte
|
|
||||||
var sec *ecdsa.PrivateKey
|
|
||||||
if jskb, err = base64.URLEncoding.DecodeString(jwtSecret); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if sec, err = x509.ParseECPrivateKey(jskb); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
spk := &sec.PublicKey
|
|
||||||
var spkb []byte
|
|
||||||
if spkb, err = x509.MarshalPKIXPublicKey(spk); chk.E(err) {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
spub := base64.URLEncoding.EncodeToString(spkb)
|
|
||||||
ev := MakeJWTEvent(spub)
|
|
||||||
if err = ev.Sign(sign); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.I.F("%s", ev.SerializeIndented())
|
|
||||||
}
|
|
||||||
@@ -19,30 +19,30 @@ const (
|
|||||||
NIP98Prefix = "Nostr"
|
NIP98Prefix = "Nostr"
|
||||||
)
|
)
|
||||||
|
|
||||||
func MakeNIP98Event(u, method, hash string) (ev *event.T) {
|
func MakeNIP98Event(u, method, hash string, expiry int64) (ev *event.T) {
|
||||||
if hash != "" {
|
var t []*tag.T
|
||||||
ev = &event.T{
|
t = append(t, tag.New("u", u))
|
||||||
CreatedAt: timestamp.Now(),
|
if expiry > 0 {
|
||||||
Kind: kind.HTTPAuth,
|
t = append(t,
|
||||||
Tags: tags.New(
|
tag.New("expiration", timestamp.FromUnix(expiry).String()))
|
||||||
tag.New("u", u),
|
|
||||||
tag.New("method", strings.ToUpper(method)),
|
|
||||||
tag.New("payload", hash),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ev = &event.T{
|
t = append(t,
|
||||||
CreatedAt: timestamp.Now(),
|
tag.New("method", strings.ToUpper(method)))
|
||||||
Kind: kind.HTTPAuth,
|
}
|
||||||
Tags: tags.New(tag.New("u", u),
|
if hash != "" {
|
||||||
tag.New("method", strings.ToUpper(method))),
|
t = append(t, tag.New("payload", hash))
|
||||||
}
|
}
|
||||||
|
ev = &event.T{
|
||||||
|
CreatedAt: timestamp.Now(),
|
||||||
|
Kind: kind.HTTPAuth,
|
||||||
|
Tags: tags.New(t...),
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddNIP98Header(r *http.Request, ur *url.URL, method, hash string, sign signer.I) (err error) {
|
func AddNIP98Header(r *http.Request, ur *url.URL, method, hash string,
|
||||||
ev := MakeNIP98Event(ur.String(), method, hash)
|
sign signer.I, expiry int64) (err error) {
|
||||||
|
ev := MakeNIP98Event(ur.String(), method, hash, expiry)
|
||||||
if err = ev.Sign(sign); chk.E(err) {
|
if err = ev.Sign(sign); chk.E(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
= http authentication
|
|
||||||
:toc:
|
|
||||||
|
|
||||||
The nostr protocol includes an authentication mechanism based on nostr events and HTTP headers that you can read our local version here: link:98.adoc[nip-98]; however, there are numerous tools you might want to use to interact with HTTP listeners such as the simplified HTTP protocol described in link:../readme.adoc#simplified-nostr[the readme] which would permit the deployment of a relay with auth-controlled access, but be able to use tooling and clients that can't be easily augmented to use link:98.adoc[nip-98], and use PEM encoded secret keys that can be used with standards compliant JWT implementations to generate them.
|
|
||||||
|
|
||||||
The main use cases are for testing and administrative purposes, but it may also enable devices such as old/low-spec ebook readers' browsers to interact with nostr based document libraries such as Alexandria, for devices that can neither do websockets nor easily handle NIP-98 headers for paid/private document repositories.
|
|
||||||
|
|
||||||
HTTP versions of the protocol are not just about enabling devices that may not be able to do websockets, or be easily programmable even still to do NIP-98 authentication, they are also simpler and enable lowering the overhead caused by the use of sockets for simple request/response protocols that have no need to maintain liveness or store socket state information.
|
|
||||||
|
|
||||||
== JWT Authentication Registration Event
|
|
||||||
[[authevent]]
|
|
||||||
|
|
||||||
The simplest way to enable this for a nostr relay is to create a new event kind which allows a user to associate a JWT public key token with their nostr public key, and then the relay's access control mechanism can search for it and authorize using JWT in place of NIP-98 or NIP-42 websocket auth (to be implemented later).
|
|
||||||
|
|
||||||
To make this mesh with the standard NIP-01 specification for indexing, the simplest way is to place the JWT public key in a `J` tag and then when a request is received with the JWT token, after it is decoded, the event tagged with this token can be found with a simple query and then the associated public key of the user that published the event with this token can be located.
|
|
||||||
|
|
||||||
So, the event structure is as follows:
|
|
||||||
|
|
||||||
[source,json]
|
|
||||||
----
|
|
||||||
{
|
|
||||||
"id": "<event id>",
|
|
||||||
"kind": 13004,
|
|
||||||
"pubkey": "<user pubkey>",
|
|
||||||
"created_at": 1234567890,
|
|
||||||
"content": "",
|
|
||||||
"tags": [
|
|
||||||
["J","<JWT public key in x509 binary in base64 URL encoding>"]
|
|
||||||
],
|
|
||||||
"sig": "<signature matching user pubkey>"
|
|
||||||
}
|
|
||||||
----
|
|
||||||
|
|
||||||
By publishing this event, with the JWT secret key, the user proves control of the JWT token and that it is equivalent as their own nsec for signing JWT signed HTTP requests in place of using the NIP-98 authentication.
|
|
||||||
|
|
||||||
NOTE: it should be possible to later also use this to assign other authentication mechanisms trust, should other mechanisms be usable for other environments that would otherwise not be able to work with nostr relays. Other mechanisms would use a different tag key than `J` which stands for JWT.
|
|
||||||
|
|
||||||
== JWT Authentication Protocol
|
|
||||||
|
|
||||||
In every respect, other than the different key/signature algorithm, and authentication event token encoding, this protocol is identical to NIP-98
|
|
||||||
|
|
||||||
This means that the JWT must include the HTTP method, the full URL of the request, and a base 10 encoded timestamp that is within 15 seconds of the server/relay current unix time:
|
|
||||||
|
|
||||||
The encoding should then be:
|
|
||||||
|
|
||||||
[source,json]
|
|
||||||
----
|
|
||||||
{
|
|
||||||
"iss":"<hex pubkey of user>",
|
|
||||||
"typ":"message",
|
|
||||||
"sub":"http://example.com/path/to/endpoint",
|
|
||||||
"alg":"ES256"
|
|
||||||
"iat":1234567890,
|
|
||||||
}
|
|
||||||
----
|
|
||||||
|
|
||||||
The JWT token must then have a signature on this string, and that signature match the JWT token registered in the kind 30050.
|
|
||||||
|
|
||||||
An additional field `exp` can be present and will allow the client to generate one certificate and use it until the time specified. The authorization can be revoked by deleting or updating the link:#authevent[registration event]
|
|
||||||
|
|
||||||
== The Actual Authorization
|
|
||||||
|
|
||||||
Where the NIP-98 uses:
|
|
||||||
|
|
||||||
Authorization: Nostr <base64 encoded auth event>
|
|
||||||
|
|
||||||
to comport with the standard for JWT:
|
|
||||||
|
|
||||||
Authorization: Bearer <jwt.token.string>
|
|
||||||
|
|
||||||
The relay must be able to distinguish between the `Nostr` and `Bearer` sentinels in the header value for `Authorization` and then it can validate the JWT token, and then search for the JWT registration, and then use the associated nostr public key to check for permission to do whatever the POST (or QUERY) body specifies.
|
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"realy.lol/event"
|
"realy.lol/event"
|
||||||
|
"realy.lol/ints"
|
||||||
"realy.lol/kind"
|
"realy.lol/kind"
|
||||||
"realy.lol/tag"
|
"realy.lol/tag"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,7 @@ var ErrMissingKey = fmt.Errorf(
|
|||||||
//
|
//
|
||||||
// A VerifyJWTFunc should be provided in order to search the event store for a
|
// 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.
|
// kind 13004 with a JWT signer pubkey that is granted authority for the request.
|
||||||
func CheckAuth(r *http.Request, vfn VerifyJWTFunc, tolerance ...time.Duration) (valid bool,
|
func CheckAuth(r *http.Request, tolerance ...time.Duration) (valid bool,
|
||||||
pubkey []byte, err error) {
|
pubkey []byte, err error) {
|
||||||
val := r.Header.Get(HeaderKey)
|
val := r.Header.Get(HeaderKey)
|
||||||
if val == "" {
|
if val == "" {
|
||||||
@@ -69,27 +70,45 @@ func CheckAuth(r *http.Request, vfn VerifyJWTFunc, tolerance ...time.Duration) (
|
|||||||
ev.Kind.K, ev.Kind.Name(), kind.HTTPAuth.K, kind.HTTPAuth.Name())
|
ev.Kind.K, ev.Kind.Name(), kind.HTTPAuth.K, kind.HTTPAuth.Name())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The created_at timestamp MUST be within a reasonable time window (suggestion ~60~ 15 seconds)
|
// if there is an expiration timestamp it supersedes the created_at for validity.
|
||||||
ts := ev.CreatedAt.I64()
|
exp := ev.Tags.GetAll(tag.New("expiration"))
|
||||||
tn := time.Now().Unix()
|
if exp.Len() > 1 {
|
||||||
if ts < tn-tolerate || ts > tn+tolerate {
|
err = errorf.E("more than one \"expiration\" tag found: '%s'", exp.MarshalTo(nil))
|
||||||
err = errorf.E("timestamp %d is more than %d seconds divergent from now %d",
|
|
||||||
ts, tolerate, tn)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// we are going to say anything not specified in nip-98 is invalid also, such as extra tags
|
var expiring bool
|
||||||
if ev.Tags.Len() < 2 {
|
if exp.Len() == 1 {
|
||||||
err = errorf.E("other than exactly 2 tags found in event\n%s",
|
ex := ints.New(0)
|
||||||
ev.Tags.MarshalTo(nil))
|
exp1 := exp.F()[0]
|
||||||
return
|
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.I64()
|
||||||
|
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(tag.New("u"))
|
ut := ev.Tags.GetAll(tag.New("u"))
|
||||||
if ut.Len() != 1 {
|
if ut.Len() > 1 {
|
||||||
err = errorf.E("more than one \"u\" tag found: '%s'", ut.MarshalTo(nil))
|
err = errorf.E("more than one \"u\" tag found: '%s'", ut.MarshalTo(nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uts := ut.Value()
|
uts := ut.Value()
|
||||||
// The u tag MUST be exactly the same as the absolute request URL (including query parameters).
|
// The u tag MUST be exactly the same as the absolute request URL (including query
|
||||||
|
// parameters).
|
||||||
proto := r.URL.Scheme
|
proto := r.URL.Scheme
|
||||||
// if this came through a proxy we need to get the protocol to match the event
|
// 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 != "" {
|
if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
|
||||||
@@ -102,22 +121,31 @@ func CheckAuth(r *http.Request, vfn VerifyJWTFunc, tolerance ...time.Duration) (
|
|||||||
evUrl := string(uts[0].Value())
|
evUrl := string(uts[0].Value())
|
||||||
// log.I.S(r)
|
// log.I.S(r)
|
||||||
log.T.F("full URL: %s event u tag value: %s", fullUrl, evUrl)
|
log.T.F("full URL: %s event u tag value: %s", fullUrl, evUrl)
|
||||||
if 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 does not start with the u tag URL %s",
|
||||||
|
fullUrl, evUrl)
|
||||||
|
}
|
||||||
|
} else if fullUrl != evUrl {
|
||||||
err = errorf.E("request has URL %s but signed nip-98 event has url %s",
|
err = errorf.E("request has URL %s but signed nip-98 event has url %s",
|
||||||
fullUrl, string(uts[0].Value()))
|
fullUrl, string(uts[0].Value()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The method tag MUST be the same HTTP method used for the requested resource.
|
if !expiring {
|
||||||
mt := ev.Tags.GetAll(tag.New("method"))
|
// The method tag MUST be the same HTTP method used for the requested resource.
|
||||||
if mt.Len() != 1 {
|
mt := ev.Tags.GetAll(tag.New("method"))
|
||||||
err = errorf.E("more than one \"method\" tag found: '%s'", mt.MarshalTo(nil))
|
if mt.Len() != 1 {
|
||||||
return
|
err = errorf.E("more than one \"method\" tag found: '%s'", mt.MarshalTo(nil))
|
||||||
}
|
return
|
||||||
mts := mt.Value()
|
}
|
||||||
if strings.ToLower(string(mts[0].Value())) != strings.ToLower(r.Method) {
|
mts := mt.Value()
|
||||||
err = errorf.E("request has method %s but event has method %s",
|
if strings.ToLower(string(mts[0].Value())) != strings.ToLower(r.Method) {
|
||||||
string(mts[0].Value()), r.Method)
|
err = errorf.E("request has method %s but event has method %s",
|
||||||
return
|
string(mts[0].Value()), r.Method)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if valid, err = ev.Verify(); chk.E(err) {
|
if valid, err = ev.Verify(); chk.E(err) {
|
||||||
return
|
return
|
||||||
@@ -126,34 +154,6 @@ func CheckAuth(r *http.Request, vfn VerifyJWTFunc, tolerance ...time.Duration) (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
pubkey = ev.Pubkey
|
pubkey = ev.Pubkey
|
||||||
case strings.HasPrefix(val, JWTPrefix):
|
|
||||||
if vfn == nil {
|
|
||||||
err = errorf.E("JWT bearer header found but no JWT verifier function provided")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
split := strings.Split(val, " ")
|
|
||||||
if len(split) == 1 {
|
|
||||||
err = errorf.E("missing JWT auth token 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
|
|
||||||
}
|
|
||||||
// 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()
|
|
||||||
if pubkey, valid, err = VerifyJWTtoken(split[1], fullUrl, vfn); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
err = errorf.E("invalid '%s' value: '%s'", HeaderKey, val)
|
err = errorf.E("invalid '%s' value: '%s'", HeaderKey, val)
|
||||||
return
|
return
|
||||||
|
|||||||
4012
query.json
4012
query.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user