Pure-stdlib Go implementation of the whole control plane: same nexusgate.json schema (unknown fields preserved verbatim, explicit null/false tri-states modeled), same HTTP API and error strings, embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt auth, X25519 Reality keys, panel-hosted keyStore and heartbeat reconciliation included. Version stays 0.6.8. Verified: Go tests 33/33 (scrypt + Reality vectors generated from the Node implementation, subscription goldens byte-identical), Node suite 52/52 untouched, differential E2E against the Node panel 50/50 including two-way DB swap. See panel-go/PARITY.md and panel-go/INSTALL-GO.md.
198 lines
5.1 KiB
Go
198 lines
5.1 KiB
Go
package main
|
|
|
|
// Port of lib/auth.js: scrypt secret hashing, random tokens, cookie parsing
|
|
// and the in-memory session manager.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// HashSecret hashes secret with a fresh random 16-byte salt (hex string),
|
|
// matching JS hashSecret's default parameter.
|
|
func HashSecret(secret string) string {
|
|
var saltBytes [16]byte
|
|
if _, err := rand.Read(saltBytes[:]); err != nil {
|
|
panic(err)
|
|
}
|
|
return HashSecretWithSalt(secret, hex.EncodeToString(saltBytes[:]))
|
|
}
|
|
|
|
// HashSecretWithSalt returns `scrypt:<salt>:<hex hash>`. The salt string is
|
|
// fed to scrypt as its raw UTF-8 bytes, exactly like Node.
|
|
func HashSecretWithSalt(secret, salt string) string {
|
|
hash := ScryptHash(secret, salt)
|
|
return "scrypt:" + salt + ":" + hex.EncodeToString(hash)
|
|
}
|
|
|
|
// VerifySecret mirrors JS verifySecret: split on ':', destructure the first
|
|
// three parts, require kind == "scrypt", decode the expected hex and compare
|
|
// in constant time. Any malformed input returns false.
|
|
func VerifySecret(secret, encoded string) bool {
|
|
parts := strings.Split(encoded, ":")
|
|
if len(parts) < 3 {
|
|
return false
|
|
}
|
|
kind, salt, expectedHex := parts[0], parts[1], parts[2]
|
|
if kind != "scrypt" || salt == "" || expectedHex == "" {
|
|
return false
|
|
}
|
|
expected, err := hex.DecodeString(expectedHex)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
actual := ScryptHash(secret, salt)
|
|
if len(actual) != len(expected) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare(actual, expected) == 1
|
|
}
|
|
|
|
// RandomToken mirrors JS randomToken: random bytes, base64url, no padding.
|
|
func RandomToken(nBytes int) string {
|
|
b := make([]byte, nBytes)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// TokenFingerprint is the SHA-256 hex digest of s.
|
|
func TokenFingerprint(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// ParseCookies mirrors JS parseCookies: split on ';', split each part on the
|
|
// first '=', trim both sides, decodeURIComponent key and value, then drop
|
|
// pairs with an empty key; later duplicates overwrite earlier ones.
|
|
//
|
|
// Deviation from JS: decodeURIComponent throws on a malformed escape, which
|
|
// aborts the whole JS parse; here a pair whose key or value fails to decode
|
|
// is skipped instead (and url.PathUnescape, unlike decodeURIComponent, does
|
|
// not reject invalid UTF-8 byte sequences produced by %XX escapes).
|
|
func ParseCookies(header string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, part := range strings.Split(header, ";") {
|
|
at := strings.Index(part, "=")
|
|
if at < 0 {
|
|
continue
|
|
}
|
|
key, err := url.PathUnescape(strings.TrimSpace(part[:at]))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
value, err := url.PathUnescape(strings.TrimSpace(part[at+1:]))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if key == "" {
|
|
continue
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Session is one login session. ExpiresAt is a time.Time where JS stores
|
|
// epoch milliseconds.
|
|
type Session struct {
|
|
ID string
|
|
UserID string
|
|
CSRF string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// SessionManager is the in-memory session store from lib/auth.js.
|
|
type SessionManager struct {
|
|
TTL time.Duration
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*Session
|
|
}
|
|
|
|
// NewSessionManager builds a manager whose sessions live for hours hours
|
|
// (JS: ttlMs = hours * 60 * 60 * 1000).
|
|
func NewSessionManager(hours float64) *SessionManager {
|
|
return &SessionManager{
|
|
TTL: time.Duration(hours * float64(time.Hour)),
|
|
sessions: map[string]*Session{},
|
|
}
|
|
}
|
|
|
|
// Create mints a session for userID: id = randomToken(32),
|
|
// csrf = randomToken(24), expiresAt = now + TTL.
|
|
func (m *SessionManager) Create(userID string) *Session {
|
|
session := &Session{
|
|
ID: RandomToken(32),
|
|
UserID: userID,
|
|
CSRF: RandomToken(24),
|
|
ExpiresAt: time.Now().Add(m.TTL),
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.sessions == nil {
|
|
m.sessions = map[string]*Session{}
|
|
}
|
|
m.sessions[session.ID] = session
|
|
return session
|
|
}
|
|
|
|
// Get returns the session named by the ng_session cookie, or nil. An expired
|
|
// session is deleted and reported as missing (JS: expiresAt <= Date.now()).
|
|
func (m *SessionManager) Get(r *http.Request) *Session {
|
|
id := sessionIDFromRequest(r)
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
session, ok := m.sessions[id]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !session.ExpiresAt.After(time.Now()) {
|
|
delete(m.sessions, id)
|
|
return nil
|
|
}
|
|
return session
|
|
}
|
|
|
|
// Delete drops the session named by the ng_session cookie, if any.
|
|
func (m *SessionManager) Delete(r *http.Request) {
|
|
id := sessionIDFromRequest(r)
|
|
if id == "" {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.sessions, id)
|
|
}
|
|
|
|
// Prune removes every expired session.
|
|
func (m *SessionManager) Prune() {
|
|
now := time.Now()
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for id, session := range m.sessions {
|
|
if !session.ExpiresAt.After(now) {
|
|
delete(m.sessions, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sessionIDFromRequest(r *http.Request) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
return ParseCookies(r.Header.Get("Cookie"))["ng_session"]
|
|
}
|