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::`. 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"] }