// Package installapi is a reference implementation of the installer // registration API (docs/installer-registration-api.md), meant to be mounted // by the portal backend under /portal/api. Account storage is behind the // Accounts interface so the real portal database can be plugged in. // // Secrets: passwords are hashed by Accounts.HashPassword during /register and // the plaintext is never retained or logged; codes are stored only as salted // SHA-256 and compared in constant time; logs carry masked emails only. package installapi import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "net" "net/http" "net/mail" "strconv" "strings" "sync" "time" "unicode" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" ) var ( ErrInvalidCredentials = errors.New("invalid_credentials") ErrTOTPRequired = errors.New("totp_required") ErrTOTPInvalid = errors.New("totp_invalid") ) // Accounts is implemented by the portal backend. type Accounts interface { EmailExists(email string) (bool, error) HashPassword(password string) (string, error) CreateAccount(name, email, passwordHash string) (userID string, err error) Authenticate(email, password, totp string) (userID string, err error) MintToken(userID, name string, client map[string]string) (token string, err error) } // Policy mirrors capabilities.passwordPolicy. type Policy struct { MinLength int `json:"minLength"` RequireLetter bool `json:"requireLetter"` RequireDigit bool `json:"requireDigit"` RequireSymbol bool `json:"requireSymbol"` DisallowEmail bool `json:"disallowEmail"` } var DefaultPolicy = Policy{MinLength: 10, RequireLetter: true, RequireDigit: true, DisallowEmail: true} func (p Policy) check(pw, email string) string { if len([]rune(pw)) < p.MinLength { return "Password must be at least " + itoa(p.MinLength) + " characters." } var l, d, s bool for _, r := range pw { switch { case unicode.IsLetter(r): l = true case unicode.IsDigit(r): d = true case !unicode.IsSpace(r): s = true } } switch { case p.RequireLetter && !l: return "Password must contain a letter." case p.RequireDigit && !d: return "Password must contain a number." case p.RequireSymbol && !s: return "Password must contain a symbol." } if p.DisallowEmail { if i := strings.Index(email, "@"); i >= 3 && strings.Contains(strings.ToLower(pw), strings.ToLower(email[:i])) { return "Password must not contain your email address." } } return "" } func itoa(n int) string { return strconv.Itoa(n) } type pending struct { name, email, hash string exists bool salt []byte codeHash [32]byte expires time.Time attempts int } // Server holds the API state. type Server struct { Accounts Accounts Mail mailer.Sender Limiter *mailer.Limiter Policy Policy CodeTTL time.Duration MaxAttempt int TrustProxy bool // honour X-Forwarded-For (only behind your own proxy) Log func(format string, args ...any) Now func() time.Time mu sync.Mutex pending map[string]*pending loginRL *mailer.Limiter } func New(a Accounts, m mailer.Sender) *Server { return &Server{Accounts: a, Mail: m, Limiter: mailer.NewLimiter(mailer.DefaultLimits), Policy: DefaultPolicy, CodeTTL: 15 * time.Minute, MaxAttempt: 5, Log: func(string, ...any) {}, Now: time.Now, pending: map[string]*pending{}, loginRL: mailer.NewLimiter(mailer.Limits{PerEmailHour: 20, PerIPHour: 60})} } // Handler serves /install/* (mount under /portal/api). func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/install/capabilities", s.capabilities) mux.HandleFunc("/install/register", s.post(s.register)) mux.HandleFunc("/install/verify", s.post(s.verify)) mux.HandleFunc("/install/resend", s.post(s.resend)) mux.HandleFunc("/install/login", s.post(s.login)) return mux } type apiErr struct { status int body map[string]any } func fail(status int, code, msg string) *apiErr { return &apiErr{status, map[string]any{"error": code, "message": msg}} } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func (s *Server) post(fn func(r *http.Request, body map[string]any) (int, any, *apiErr)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, 405, map[string]any{"error": "method_not_allowed"}) return } var body map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil { writeJSON(w, 400, map[string]any{"error": "bad_json", "message": "Invalid request."}) return } st, out, e := fn(r, body) if e != nil { writeJSON(w, e.status, e.body) return } writeJSON(w, st, out) } } func str(b map[string]any, k string) string { v, _ := b[k].(string); return v } func (s *Server) ip(r *http.Request) string { if s.TrustProxy { if f := r.Header.Get("X-Forwarded-For"); f != "" { return strings.TrimSpace(strings.Split(f, ",")[0]) } } h, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return h } func limited(err error) *apiErr { var le *mailer.LimitError if errors.As(err, &le) { e := fail(429, "rate_limited", "Too many attempts. Please wait and try again.") e.body["retryAfterSeconds"] = int(le.RetryAfter.Seconds() + 0.999) return e } return nil } func (s *Server) capabilities(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"installApi": true, "emailVerification": true, "passwordPolicy": s.Policy}) } func newID() string { b := make([]byte, 24) _, _ = rand.Read(b) return hex.EncodeToString(b) } func hashCode(salt []byte, code string) [32]byte { return sha256.Sum256(append(append([]byte{}, salt...), []byte(code)...)) } func normEmail(e string) (string, bool) { e = strings.TrimSpace(e) a, err := mail.ParseAddress(e) if err != nil || a.Address != e || len(e) > 254 || strings.ContainsAny(e, "\r\n") { return "", false } return strings.ToLower(e), true } // issueCode creates/refreshes a code for p and emails it. func (s *Server) issueCode(p *pending, ip string) *apiErr { if err := s.Limiter.Allow(p.email, ip); err != nil { s.Log("install: code email to %s rate-limited: %v", mailer.MaskEmail(p.email), err) return limited(err) } code, err := mailer.NewCode() if err != nil { return fail(500, "internal", "Please try again.") } p.salt = []byte(newID()) p.codeHash = hashCode(p.salt, code) p.expires = s.Now().Add(s.CodeTTL) p.attempts = 0 var m mailer.Message if p.exists { m = mailer.AccountExistsEmail(p.email) } else { m = mailer.CodeEmail(p.email, p.name, code, int(s.CodeTTL.Minutes()), "the Verae installer") } if err := s.Mail.Send(m); err != nil { s.Log("install: send to %s failed: %v", mailer.MaskEmail(p.email), err) return fail(502, "email_failed", "We could not send the email. Please try again later.") } s.Log("install: code email sent to %s (exists=%v)", mailer.MaskEmail(p.email), p.exists) return nil } func (s *Server) register(r *http.Request, b map[string]any) (int, any, *apiErr) { name := strings.TrimSpace(str(b, "name")) email, ok := normEmail(str(b, "email")) pw := str(b, "password") if name == "" || len(name) > 200 { return 0, nil, fail(400, "name_required", "Please enter your name.") } if !ok { return 0, nil, fail(400, "invalid_email", "Please enter a valid email address.") } if msg := s.Policy.check(pw, email); msg != "" { return 0, nil, fail(400, "weak_password", msg) } exists, err := s.Accounts.EmailExists(email) if err != nil { return 0, nil, fail(500, "internal", "Please try again.") } p := &pending{name: name, email: email, exists: exists} if !exists { if p.hash, err = s.Accounts.HashPassword(pw); err != nil { return 0, nil, fail(500, "internal", "Please try again.") } } pw = "" if e := s.issueCode(p, s.ip(r)); e != nil { return 0, nil, e } id := newID() s.mu.Lock() s.gc() s.pending[id] = p s.mu.Unlock() return 202, map[string]any{"pendingId": id, "expiresInSeconds": int(s.CodeTTL.Seconds()), "resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil } func (s *Server) gc() { now := s.Now() for k, p := range s.pending { if now.After(p.expires.Add(time.Hour)) { delete(s.pending, k) } } } func (s *Server) resend(r *http.Request, b map[string]any) (int, any, *apiErr) { s.mu.Lock() p := s.pending[str(b, "pendingId")] s.mu.Unlock() if p == nil { return 0, nil, fail(410, "expired", "This sign-up has expired. Please start again.") } s.mu.Lock() defer s.mu.Unlock() if e := s.issueCode(p, s.ip(r)); e != nil { return 0, nil, e } return 202, map[string]any{"resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil } func (s *Server) verify(r *http.Request, b map[string]any) (int, any, *apiErr) { id := str(b, "pendingId") code := strings.ReplaceAll(strings.TrimSpace(str(b, "code")), " ", "") s.mu.Lock() p := s.pending[id] if p == nil || s.Now().After(p.expires) { s.mu.Unlock() return 0, nil, fail(410, "expired", "This code has expired. Click 'Send a new code'.") } h := hashCode(p.salt, code) if p.exists || subtle.ConstantTimeCompare(h[:], p.codeHash[:]) != 1 { p.attempts++ left := s.MaxAttempt - p.attempts if left <= 0 { delete(s.pending, id) s.mu.Unlock() return 0, nil, fail(410, "too_many_attempts", "Too many wrong codes. Please start again.") } s.mu.Unlock() e := fail(400, "invalid_code", "That code is not correct.") e.body["attemptsLeft"] = left return 0, nil, e } delete(s.pending, id) // single use s.mu.Unlock() uid, err := s.Accounts.CreateAccount(p.name, p.email, p.hash) if err != nil { return 0, nil, fail(500, "internal", "Could not create the account.") } tn := str(b, "tokenName") if tn == "" { tn = "Verae installer" } tok, err := s.Accounts.MintToken(uid, tn, nil) if err != nil { return 0, nil, fail(500, "internal", "Account created; sign in to get a key.") } s.Log("install: account created for %s", mailer.MaskEmail(p.email)) return 201, map[string]any{"token": tok, "name": tn}, nil } func (s *Server) login(r *http.Request, b map[string]any) (int, any, *apiErr) { email, ok := normEmail(str(b, "email")) if !ok { return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.") } if err := s.loginRL.Allow(email, s.ip(r)); err != nil { return 0, nil, limited(err) } uid, err := s.Accounts.Authenticate(email, str(b, "password"), strings.TrimSpace(str(b, "totpCode"))) switch { case errors.Is(err, ErrTOTPRequired): return 0, nil, fail(401, "totp_required", "Enter the code from your authenticator app.") case errors.Is(err, ErrTOTPInvalid): return 0, nil, fail(401, "totp_invalid", "The authenticator code is not correct.") case err != nil: s.Log("install: login failed for %s", mailer.MaskEmail(email)) return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.") } tn := str(b, "tokenName") if tn == "" { tn = "Verae installer" } var client map[string]string if c, ok := b["client"].(map[string]any); ok { client = map[string]string{} for k, v := range c { if sv, ok := v.(string); ok && len(sv) < 200 { client[k] = sv } } } tok, err := s.Accounts.MintToken(uid, tn, client) if err != nil { return 0, nil, fail(500, "internal", "Please try again.") } s.Log("install: key issued for %s", mailer.MaskEmail(email)) return 201, map[string]any{"token": tok, "name": tn}, nil }