diff --git a/Makefile b/Makefile index 1fd9b62..1e0d79c 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ SOURCE := $(shell find db env lib lnd public server -type f) main.go delphi.market: $(SOURCE) npm run build - tailwindcss -i public/css/tw-input.css -o public/css/tailwind.css + tailwindcss -i public/css/base.css -o public/css/tailwind.css templ generate -path server/router/pages go build -o delphi.market . @@ -13,7 +13,7 @@ build: delphi.market run: npm run build - tailwindcss -i public/css/tw-input.css -o public/css/tailwind.css + tailwindcss -i public/css/base.css -o public/css/tailwind.css templ generate -path server/router/pages go run . diff --git a/db/schema.sql b/db/schema.sql index e63610c..8170d31 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -20,6 +20,12 @@ CREATE TABLE lnauth( session_id VARCHAR(48) NOT NULL DEFAULT encode(gen_random_uuid()::text::bytea, 'base64') ); +CREATE TABLE nostr_auth( + k1 VARCHAR(64) PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + session_id VARCHAR(48) NOT NULL DEFAULT encode(gen_random_uuid()::text::bytea, 'base64') +); + CREATE TABLE invoices( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), diff --git a/go.mod b/go.mod index 8dba26e..9915b28 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/btcsuite/btcd/btcutil v1.1.3 github.com/btcsuite/btcutil v1.0.2 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 + github.com/dustin/go-humanize v1.0.1 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.11.1 github.com/lib/pq v1.10.9 @@ -21,6 +22,7 @@ require ( github.com/namsral/flag v1.7.4-pre github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/stretchr/testify v1.8.4 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 gopkg.in/guregu/null.v4 v4.0.0 ) @@ -50,7 +52,6 @@ require ( github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect github.com/decred/dcrd/lru v1.0.0 // indirect github.com/dsnet/compress v0.0.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/fergusstrange/embedded-postgres v1.10.0 // indirect github.com/go-errors/errors v1.0.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -141,7 +142,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.22.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.21.0 // indirect diff --git a/go.sum b/go.sum index 7d3e9e7..15a31e9 100644 --- a/go.sum +++ b/go.sum @@ -159,8 +159,6 @@ github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0 github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= diff --git a/public/css/tw-input.css b/public/css/base.css similarity index 85% rename from public/css/tw-input.css rename to public/css/base.css index 213b737..5672ecb 100644 --- a/public/css/tw-input.css +++ b/public/css/base.css @@ -200,4 +200,34 @@ [x-cloak] { display: none !important; } + + div.loading>span { + width: 3px; + height: 3px; + margin: 0 2px; + background-color: var(--color); + border-radius: 50%; + display: inline-block; + animation-name: jumping; + animation-duration: 1.8s; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + div.loading>span:nth-child(2) { + animation-delay: 0.4s; + } + + div.loading>span:nth-child(3) { + animation-delay: 0.8s; + } + + @keyframes jumping { + 40% { + transform: translateY(0px); + } + 50% { + transform: translateY(-5px); + } + } } \ No newline at end of file diff --git a/public/svg/file-copy-line.svg b/public/svg/file-copy-line.svg new file mode 100644 index 0000000..9e82aa2 --- /dev/null +++ b/public/svg/file-copy-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/auth/lnauth.go b/server/auth/lnauth.go index 87ddf6c..2834586 100644 --- a/server/auth/lnauth.go +++ b/server/auth/lnauth.go @@ -35,7 +35,7 @@ func NewLnAuth(action string) (*LnAuth, error) { err error ) - if _, err := rand.Read(k1); err != nil { + if _, err = rand.Read(k1); err != nil { return nil, fmt.Errorf("rand.Read error: %w", err) } diff --git a/server/auth/nostr.go b/server/auth/nostr.go new file mode 100644 index 0000000..8d78078 --- /dev/null +++ b/server/auth/nostr.go @@ -0,0 +1,182 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2/schnorr" +) + +type NostrAuth struct { + K1 string +} + +type NostrAuthCallback struct { + K1 string `query:"k1"` + Sig string `query:"sig"` + PubKey string `query:"key"` + Action string `query:"action"` + CreatedAt int `query:"created_at"` +} + +// reference: https://github.com/nbd-wtf/go-nostr +type Event struct { + ID string + PubKey string + CreatedAt int + Kind int + Tags Tags + Content string + Sig string + + // anything here will be mashed together with the main event object when serializing + // extra map[string]any +} + +type Tag []string +type Tags []Tag + +func NewNostrAuth(action string) (*NostrAuth, error) { + var ( + k1 = make([]byte, 32) + err error + ) + + if _, err = rand.Read(k1); err != nil { + return nil, fmt.Errorf("rand.Read error: %w", err) + } + + return &NostrAuth{K1: hex.EncodeToString(k1)}, nil +} + +func VerifyNostrAuth(r *NostrAuthCallback) (bool, error) { + // https://github.com/nbd-wtf/go-nostr/blob/master/signature.go + + e := Event{ + PubKey: r.PubKey, + Sig: r.Sig, + Kind: 22242, + Content: "delphi.market authentication", + Tags: []Tag{{"challenge", r.K1}}, + CreatedAt: r.CreatedAt, + } + + return e.CheckSignature() +} + +func (tag Tag) marshalTo(dst []byte) []byte { + dst = append(dst, '[') + for i, s := range tag { + if i > 0 { + dst = append(dst, ',') + } + dst = escapeString(dst, s) + } + dst = append(dst, ']') + return dst +} + +func (tags Tags) marshalTo(dst []byte) []byte { + dst = append(dst, '[') + for i, tag := range tags { + if i > 0 { + dst = append(dst, ',') + } + dst = tag.marshalTo(dst) + } + dst = append(dst, ']') + return dst +} + +func (evt *Event) Serialize() []byte { + // the serialization process is just putting everything into a JSON array + // so the order is kept. See NIP-01 + dst := make([]byte, 0) + + // the header portion is easy to serialize + // [0,"pubkey",created_at,kind,[ + dst = append(dst, []byte( + fmt.Sprintf( + "[0,\"%s\",%d,%d,", + evt.PubKey, + evt.CreatedAt, + evt.Kind, + ))...) + + // tags + dst = evt.Tags.marshalTo(dst) + dst = append(dst, ',') + + // content needs to be escaped in general as it is user generated. + dst = escapeString(dst, evt.Content) + dst = append(dst, ']') + + return dst +} + +func escapeString(dst []byte, s string) []byte { + dst = append(dst, '"') + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '"': + // quotation mark + dst = append(dst, []byte{'\\', '"'}...) + case c == '\\': + // reverse solidus + dst = append(dst, []byte{'\\', '\\'}...) + case c >= 0x20: + // default, rest below are control chars + dst = append(dst, c) + case c == 0x08: + dst = append(dst, []byte{'\\', 'b'}...) + case c < 0x09: + dst = append(dst, []byte{'\\', 'u', '0', '0', '0', '0' + c}...) + case c == 0x09: + dst = append(dst, []byte{'\\', 't'}...) + case c == 0x0a: + dst = append(dst, []byte{'\\', 'n'}...) + case c == 0x0c: + dst = append(dst, []byte{'\\', 'f'}...) + case c == 0x0d: + dst = append(dst, []byte{'\\', 'r'}...) + case c < 0x10: + dst = append(dst, []byte{'\\', 'u', '0', '0', '0', 0x57 + c}...) + case c < 0x1a: + dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x20 + c}...) + case c < 0x20: + dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x47 + c}...) + } + } + dst = append(dst, '"') + return dst +} + +func (e *Event) CheckSignature() (bool, error) { + // read and check pubkey + pk, err := hex.DecodeString(e.PubKey) + if err != nil { + return false, fmt.Errorf("event pubkey '%s' is invalid hex: %w", e.PubKey, err) + } + + pubkey, err := schnorr.ParsePubKey(pk) + if err != nil { + return false, fmt.Errorf("event has invalid pubkey '%s': %w", e.PubKey, err) + } + + // read signature + s, err := hex.DecodeString(e.Sig) + if err != nil { + return false, fmt.Errorf("signature '%s' is invalid hex: %w", e.Sig, err) + } + sig, err := schnorr.ParseSignature(s) + if err != nil { + return false, fmt.Errorf("failed to parse signature: %w", err) + } + + // check signature + hash := sha256.Sum256(e.Serialize()) + return sig.Verify(hash[:], pubkey), nil +} diff --git a/server/router/handler/auth.go b/server/router/handler/auth.go index 429b73b..8cc5d47 100644 --- a/server/router/handler/auth.go +++ b/server/router/handler/auth.go @@ -2,6 +2,7 @@ package handler import ( "database/sql" + "log" "net/http" "time" @@ -152,7 +153,131 @@ func HandleLnAuthCallback(sc context.Context) echo.HandlerFunc { } func NostrAuth(sc context.Context, c echo.Context, action string) error { - return echo.NewHTTPError(http.StatusNotImplemented) + var ( + db = sc.Db + ctx = c.Request().Context() + nostrAuth *auth.NostrAuth + sessionId string + // sessions expire in 30 days. TODO: refresh sessions + expires = time.Now().Add(60 * 60 * 24 * 30 * time.Second) + err error + ) + + if nostrAuth, err = auth.NewNostrAuth(action); err != nil { + return err + } + + if err = db.QueryRowContext( + ctx, + "INSERT INTO nostr_auth(k1) VALUES($1) RETURNING session_id", + nostrAuth.K1).Scan(&sessionId); err != nil { + return err + } + + c.SetCookie(&http.Cookie{ + Name: "session", + HttpOnly: true, + Path: "/", + Value: sessionId, + Secure: true, + Expires: expires, + }) + + return pages.NostrAuth(nostrAuth.K1, mapAction(action)).Render(context.RenderContext(sc, c), c.Response().Writer) +} + +func HandleNostrAuthCallback(sc context.Context) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + db = sc.Db + tx *sql.Tx + ctx = c.Request().Context() + query auth.NostrAuthCallback + sessionId string + userId int + ok bool + err error + pqErr *pq.Error + ) + + bail := func(code int, reason string) error { + if tx != nil { + // manual rollback is only required for tests afaik + tx.Rollback() + } + return c.JSON(code, map[string]string{"status": "ERROR", "reason": reason}) + } + + if err = c.Bind(&query); err != nil { + return bail(http.StatusInternalServerError, err.Error()) + } else if query.K1 == "" || query.Sig == "" { + return bail(http.StatusBadRequest, "bad query") + } + + if tx, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}); err != nil { + return bail(http.StatusInternalServerError, err.Error()) + } + + err = tx.QueryRow("SELECT session_id FROM nostr_auth WHERE k1 = $1 LIMIT 1", query.K1).Scan(&sessionId) + if err == sql.ErrNoRows { + return bail(http.StatusNotFound, "session not found") + } else if err != nil { + return bail(http.StatusInternalServerError, err.Error()) + } + + ok, err = auth.VerifyNostrAuth(&query) + if err != nil { + log.Println("sig error", err) + return bail(http.StatusInternalServerError, err.Error()) + } else if !ok { + return bail(http.StatusBadRequest, "bad signature") + } + + switch query.Action { + case "register": + case "signup": + { + err = tx.QueryRow(""+ + "INSERT INTO users(nostr_pubkey) VALUES ($1) "+ + "ON CONFLICT(nostr_pubkey) DO UPDATE SET nostr_pubkey = $1 "+ + "RETURNING id", query.PubKey).Scan(&userId) + if err != nil { + pqErr, ok = err.(*pq.Error) + if ok && pqErr.Code == "23505" { + return bail(http.StatusBadRequest, "user already exists") + } + return bail(http.StatusInternalServerError, err.Error()) + } + } + case "login": + { + err = tx.QueryRow("SELECT id FROM users WHERE nostr_pubkey = $1", query.PubKey).Scan(&userId) + if err == sql.ErrNoRows { + return bail(http.StatusNotFound, "user not found") + } else if err != nil { + return bail(http.StatusInternalServerError, err.Error()) + } + } + default: + { + return bail(http.StatusBadRequest, "bad action") + } + } + + if _, err = tx.Exec("INSERT INTO sessions(id, user_id) VALUES($1, $2)", sessionId, userId); err != nil { + return bail(http.StatusInternalServerError, err.Error()) + } + + if _, err = tx.Exec("DELETE FROM nostr_auth WHERE k1 = $1", query.K1); err != nil { + return bail(http.StatusInternalServerError, err.Error()) + } + + if err = tx.Commit(); err != nil { + return bail(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusOK, map[string]string{"status": "OK"}) + } } func HandleSessionCheck(sc context.Context) echo.HandlerFunc { diff --git a/server/router/pages/components/loading.templ b/server/router/pages/components/loading.templ new file mode 100644 index 0000000..39f82ff --- /dev/null +++ b/server/router/pages/components/loading.templ @@ -0,0 +1,9 @@ +package components + +templ Loading() { +
+ + + +
+} \ No newline at end of file diff --git a/server/router/pages/nostrAuth.templ b/server/router/pages/nostrAuth.templ new file mode 100644 index 0000000..75031ab --- /dev/null +++ b/server/router/pages/nostrAuth.templ @@ -0,0 +1,70 @@ +package pages + +import "git.ekzyis.com/ekzyis/delphi.market/server/router/pages/components" + +templ NostrAuth(k1 string, action string) { + + @components.Head() + + @components.Nav() +
+ @components.Figlet("random", action) + with nostr +
+
+ checking for nostr extension + @components.Loading() +
+ +
+
nostr extension found
+
+ waiting for signature + @components.Loading() +
+ + +
+
+
+
+ @components.Footer() + + +} diff --git a/server/router/router.go b/server/router/router.go index a12f07a..19e22b4 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -25,6 +25,7 @@ func Init(e *echo.Echo, sc Context) { e.GET("/signup", handler.HandleAuth(sc, "register")) e.GET("/signup/:method", handler.HandleAuth(sc, "register")) e.GET("/api/lnauth/callback", handler.HandleLnAuthCallback(sc)) + e.GET("/api/nostrauth/callback", handler.HandleNostrAuthCallback(sc)) e.GET("/session", handler.HandleSessionCheck(sc)) e.GET("/user", handler.HandleUser(sc), middleware.SessionGuard(sc))