Harden lease lifecycle durability

This commit is contained in:
kami
2026-07-30 14:34:29 +04:00
parent 1ff0af2e69
commit f6ee0e3060
40 changed files with 2108 additions and 590 deletions
+51 -25
View File
@@ -12,6 +12,8 @@ import (
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
type Surface string
@@ -72,18 +74,44 @@ func AuthorizeEvent(s Surface, typ string) error {
return nil
}
// SessionCookie carries a browser's proof of the Web-surface token. A
// top-level document load cannot set an Authorization header, so a
// bearer-only gate forces operators to run the UI with no token at all
// (AUDIT.md B18). The cookie is the browser-presentable equivalent; it is
// never a second credential, only a receipt for the same token.
// SessionCookie carries a browser's proof of a successful Web login. A
// top-level document load cannot set an Authorization header, so browser
// credentials are exchanged once for this HttpOnly receipt.
const SessionCookie = "orchestra_session"
// SessionPath is the one Web-surface endpoint exempt from the token gate,
// because it *is* the token check: it verifies the Web token itself and
// exchanges it for a cookie. Gating it would make login unreachable.
// SessionPath is the one Web-surface endpoint exempt from the session gate,
// because it verifies login credentials and exchanges them for a cookie.
const SessionPath = "/v1/ui/session"
// WebCredentials is the single configured browser operator identity. Only a
// bcrypt password hash is accepted; Orchestra has no self-service account
// creation or password-reset surface.
type WebCredentials struct {
Username string
PasswordHash string
}
func (c WebCredentials) Validate() error {
if strings.TrimSpace(c.Username) == "" {
return fmt.Errorf("web username is required")
}
if c.PasswordHash == "" {
return fmt.Errorf("web password hash is required")
}
if _, err := bcrypt.Cost([]byte(c.PasswordHash)); err != nil {
return fmt.Errorf("web password hash must be bcrypt: %w", err)
}
return nil
}
// Authenticate always performs bcrypt, even for an unknown username, so the
// response does not reveal whether the configured username was correct.
func (c WebCredentials) Authenticate(username, password string) bool {
passwordOK := bcrypt.CompareHashAndPassword([]byte(c.PasswordHash), []byte(password)) == nil
usernameOK := subtle.ConstantTimeCompare([]byte(username), []byte(c.Username)) == 1
return passwordOK && usernameOK
}
// Sessions issues and validates those receipts. Values are random and stored
// hashed, so a leaked snapshot of this map does not yield a usable cookie.
type Sessions struct {
@@ -161,9 +189,9 @@ func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
return HTTPWithSessions(tokens, nil, next)
}
// HTTPWithSessions additionally accepts a valid session cookie in place of a
// Bearer token, but only for the Web surface — every non-browser surface
// still has to present the token directly.
// HTTPWithSessions accepts a valid browser session cookie only for the Web
// surface. Supplying Sessions makes Web authentication mandatory even when
// the legacy Web bearer-token slot is empty.
func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Federation has per-worker credentials, not one shared surface token.
@@ -195,26 +223,24 @@ func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.H
if s == System {
s = Web
}
if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 {
if s == Web && sessions != nil {
ok := false
if s == Web && sessions != nil {
if c, err := r.Cookie(SessionCookie); err == nil {
ok = sessions.Valid(c.Value)
}
// The login endpoint authenticates itself, and the SPA shell
// must load before a browser can present anything. Static
// assets are not secrets; every /v1/ control path stays gated.
if r.URL.Path == SessionPath {
ok = true
}
if !strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
ok = true
}
if c, err := r.Cookie(SessionCookie); err == nil {
ok = sessions.Valid(c.Value)
}
// The login endpoint authenticates itself, and the SPA shell must
// load before a browser can present a session. Static assets are not
// secrets; every other /v1/ control path remains session-gated.
if r.URL.Path == SessionPath || (!strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodGet || r.Method == http.MethodHead)) {
ok = true
}
if !ok {
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
return
}
} else if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 {
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
return
}
if (s == Telegram || s == Ntfy) && r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "notify-only surface", http.StatusForbidden)
+27 -1
View File
@@ -5,6 +5,8 @@ import (
"net/http/httptest"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
func TestSurfaceCapabilities(t *testing.T) {
@@ -51,7 +53,9 @@ func TestSystemSurfaceDowngradedByHTTPMiddleware(t *testing.T) {
// B18: the web UI is a full control plane. A session cookie must be an
// alternative *presentation* of the Web token, never a widening of it.
func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
tokens := map[Surface]string{Web: "secret"}
// An empty legacy Web bearer-token slot must not open the browser surface:
// providing Sessions means the caller needs a session cookie.
tokens := map[Surface]string{}
sessions := &Sessions{}
h := HTTPWithSessions(tokens, sessions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -96,6 +100,28 @@ func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
}
}
func TestWebCredentialsAuthenticate(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
c := WebCredentials{Username: "operator", PasswordHash: string(hash)}
if err := c.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
if !c.Authenticate("operator", "correct horse battery staple") {
t.Fatal("correct credentials rejected")
}
for _, attempt := range []struct{ username, password string }{{"operator", "wrong"}, {"other", "correct horse battery staple"}} {
if c.Authenticate(attempt.username, attempt.password) {
t.Fatalf("invalid credentials accepted: %+v", attempt)
}
}
if err := (WebCredentials{Username: "operator", PasswordHash: "not-a-bcrypt-hash"}).Validate(); err == nil {
t.Fatal("invalid bcrypt hash accepted")
}
}
func TestFederationRequestsUseTheirOwnCredentials(t *testing.T) {
tokens := map[Surface]string{Web: "web-secret"}
h := HTTPWithSessions(tokens, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {