Security(Go): Login rate limit + WS origin allowlist + REST bearer auth
This commit is contained in:
@@ -95,13 +95,26 @@ func (a *Authenticator) AddUser(u User) {
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddAdminFromPlainPassword is a convenience for the bootstrap admin: salt is
|
||||
// empty (matching the C++ admin record), hash is SHA256(password).
|
||||
// AddAdminFromPlainPassword is a convenience for the bootstrap admin.
|
||||
// Unlike legacy convention, the admin record is given a real per-instance
|
||||
// salt — exposing an empty salt for admin while everyone else has a real
|
||||
// 16-hex one would let an unauthenticated probe distinguish admin from
|
||||
// other accounts via /get_salt alone. The cost is a tiny break in
|
||||
// users.json schema compat: admin is never persisted to users.json
|
||||
// anyway (snapshotPersistableLocked excludes it), so this is in-memory
|
||||
// only.
|
||||
func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string) {
|
||||
salt, err := NewSalt()
|
||||
if err != nil {
|
||||
// Fall back to deterministic salt derived from the password hash
|
||||
// rather than empty — preserves the uniform-shape property even
|
||||
// if crypto/rand briefly errors at startup.
|
||||
salt = ComputeSHA256(plainPassword)[:saltBytes*2]
|
||||
}
|
||||
a.AddUser(User{
|
||||
Username: username,
|
||||
PasswordHash: ComputeSHA256(plainPassword),
|
||||
Salt: "",
|
||||
PasswordHash: HashPassword(plainPassword, salt),
|
||||
Salt: salt,
|
||||
Role: "admin",
|
||||
})
|
||||
}
|
||||
@@ -200,17 +213,43 @@ func (a *Authenticator) ListUsers() []User {
|
||||
return out
|
||||
}
|
||||
|
||||
// GetSalt returns the per-user salt. If the user does not exist, returns ("", false).
|
||||
// Note: the C++ admin uses an empty salt — that is still considered "found"
|
||||
// and the empty string is returned with ok=true.
|
||||
// GetSalt returns the per-user salt for an existing user, or a
|
||||
// deterministic 16-hex pseudo-salt for an unknown user. The ok flag
|
||||
// reports which case occurred, so callers can decide whether to update
|
||||
// rate-limit / audit state — but the returned salt itself is shaped
|
||||
// identically (16 hex chars) in both cases, defeating the user-existence
|
||||
// probe an attacker would otherwise mount via /get_salt.
|
||||
//
|
||||
// The pseudo-salt is derived from a server-instance secret (the admin
|
||||
// password hash, taken at first call) mixed with the username, so the
|
||||
// same unknown user always sees the same fake salt across requests.
|
||||
// Without this, an attacker could fingerprint the "fake-salt branch"
|
||||
// by submitting the same username twice and watching for differences.
|
||||
func (a *Authenticator) GetSalt(username string) (string, bool) {
|
||||
a.mu.RLock()
|
||||
u, ok := a.users[username]
|
||||
a.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", false
|
||||
if ok {
|
||||
return u.Salt, true
|
||||
}
|
||||
return u.Salt, true
|
||||
return a.fakeSalt(username), false
|
||||
}
|
||||
|
||||
// fakeSalt derives a deterministic 16-hex value for unknown usernames.
|
||||
// The secret pepper is the bootstrap admin's password hash — present as
|
||||
// long as the server has any admin, deterministic per deployment, never
|
||||
// transmitted. Reveals nothing useful to an attacker even if reverse-
|
||||
// engineered: the only thing they can do with it is reproduce the fake
|
||||
// salt, which they already see in the response.
|
||||
func (a *Authenticator) fakeSalt(username string) string {
|
||||
a.mu.RLock()
|
||||
pepper := ""
|
||||
if admin, ok := a.users["admin"]; ok {
|
||||
pepper = admin.PasswordHash
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
digest := ComputeSHA256("yama-fake-salt|" + pepper + "|" + username)
|
||||
return digest[:saltBytes*2]
|
||||
}
|
||||
|
||||
// VerifyLogin checks a challenge-response login. The browser sends
|
||||
|
||||
Reference in New Issue
Block a user