If the IdP is misconfigured, unreachable, or the admin group is a typo, the
operator is locked out of their own server with no way back short of editing
JSON on disk. A fallback that only matters when everything else is broken is
exactly the thing you cannot add later - by then you cannot get in to add it.
Stored as PBKDF2-HMAC-SHA256 from the standard library (Go 1.24+ has it, so no
dependency), 600k iterations, per-credential salt. A password rather than a
bearer token on purpose: a break-glass credential is the one most likely to end
up in a backup or a config-management repo, and a hash survives that where a
token does not. There is no email reset flow and should not be -
--set-admin-password on the host is the reset, and whoever can run it already
has the machine.
The password is read from stdin, never a flag, so it stays out of shell history
and the process list; piping still works for automation.
Details the tests pin, each for a reason:
- the username is compared in constant time too, or a fast rejection is a
timing oracle for which usernames exist;
- the *stored* iteration count is used, so raising the constant later does not
lock out existing passwords;
- the throttle grows with consecutive failures but stays bounded and forgives
after a quiet minute - a break-glass credential an attacker can lock out is
a denial of service against the one person who needs it;
- sessions are MAC-checked before anything in them is read, and rotating the
secret invalidates every one at once, which is how they are revoked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ce6d0c2f64
commit
89a5ff9139
@@ -11,6 +11,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
@@ -36,6 +37,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/adminauth"
|
||||
"echo-lot.app/server/internal/canarydns"
|
||||
"echo-lot.app/server/internal/compat"
|
||||
"echo-lot.app/server/internal/config"
|
||||
@@ -80,6 +82,8 @@ func run() error {
|
||||
return system.InstallSystemd(cfg.SelfUpdateAPI)
|
||||
case actions.UninstallSystemd:
|
||||
return system.UninstallSystemd()
|
||||
case actions.SetAdminPassword:
|
||||
return setAdminPassword(cfg)
|
||||
case actions.SelfUpdate:
|
||||
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
|
||||
}
|
||||
@@ -516,3 +520,48 @@ func publicControlURL(cfg *config.Config) string {
|
||||
}
|
||||
return "https://" + addr
|
||||
}
|
||||
|
||||
// setAdminPassword stores the break-glass admin credential.
|
||||
//
|
||||
// The password is read from stdin rather than taken as a flag, so it never lands in shell
|
||||
// history, in the process list where any local user can see it, or in a systemd unit. Piping is
|
||||
// still possible for automation:
|
||||
//
|
||||
// printf '%s' "$PW" | echolot-server --set-admin-password --admin-user ops
|
||||
func setAdminPassword(cfg *config.Config) error {
|
||||
st, err := store.Open(cfg.StateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("state store: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "New password for %q (input is not echoed if this is a terminal): ", cfg.AdminUser)
|
||||
pw, err := readSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
cred, err := adminauth.NewCredential(cfg.AdminUser, pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.SetLocalAdmin(cred); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Break-glass admin %q set. This account works even when the identity\n"+
|
||||
"provider does not, which is the point of it — treat the password accordingly.\n", cfg.AdminUser)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readSecret reads one line from stdin, without echo where the terminal allows it.
|
||||
func readSecret() (string, error) {
|
||||
restore, _ := system.DisableEcho(os.Stdin)
|
||||
if restore != nil {
|
||||
defer restore()
|
||||
}
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user