bastion 0.60.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s

This commit is contained in:
Privatae
2026-11-02 00:10:58 +00:00
parent 4a5c310482
commit 4f8285ef18
100 changed files with 11537 additions and 840 deletions
+114 -13
View File
@@ -95,6 +95,9 @@ const uiEnrolTTL = 10 * time.Minute
// console — and that reason does not apply in a browser, so the default here is
// the stricter one.
type uiEnrol struct {
// Machine: the owner's own code (stepup_machine.go), not a Host's. Host
// is empty then.
Machine bool
Host string
SecretB32 string
URI string
@@ -198,15 +201,23 @@ type uiSetup struct {
func (u *uiServer) setupRoutes(mux *http.ServeMux) *uiSetup {
s := &uiSetup{u: u, enrols: newUIEnrolStore()}
mux.HandleFunc("/setup", u.guarded(s.page))
mux.HandleFunc("/setup/pair", u.guarded(s.pair))
mux.HandleFunc("/setup/stepup/start", u.guarded(s.stepupStart))
mux.HandleFunc("/setup/stepup/qr", u.guarded(s.stepupQR))
mux.HandleFunc("/setup/stepup/key", u.guarded(s.stepupKey))
mux.HandleFunc("/setup/stepup/confirm", u.guarded(s.stepupConfirm))
mux.HandleFunc("/setup/folder/check", u.guarded(s.folderCheck))
mux.HandleFunc("/setup/folder/propose", u.guarded(s.folderPropose))
mux.HandleFunc("/setup/anchor/choose", u.guarded(s.anchorChoose))
mux.HandleFunc("/setup/pair", u.adminOnly(s.pair))
mux.HandleFunc("/setup/stepup/start", u.adminOnly(s.stepupStart))
mux.HandleFunc("/setup/stepup/qr", u.adminOnly(s.stepupQR))
mux.HandleFunc("/setup/stepup/key", u.adminOnly(s.stepupKey))
mux.HandleFunc("/setup/stepup/confirm", u.adminOnly(s.stepupConfirm))
// The folder check stats any path the page posts, as the daemon: root on a
// whole-machine install. It exists only to help fill in the proposal,
// which is the administrator's, so it is the administrator's too; from the
// owner's lane it would tell a program running as the person what exists
// in folders that account cannot read.
mux.HandleFunc("/setup/folder/check", u.adminOnly(s.folderCheck))
mux.HandleFunc("/setup/folder/propose", u.adminOnly(s.folderPropose))
mux.HandleFunc("/setup/anchor/choose", u.adminOnly(s.anchorChoose))
mux.HandleFunc("/setup/owner/name", u.guarded(s.ownerName))
// The owner's acts on a Host (uisetup_owner.go).
mux.HandleFunc("/setup/host/disconnect", u.guarded(s.hostDisconnect))
mux.HandleFunc("/setup/host/release", u.guarded(s.hostRelease))
return s
}
@@ -217,17 +228,21 @@ type uiSetupHost struct {
State string
Active bool
IsAnchor bool
Serving bool // its loopback control port answers — add-folder goes there
Stepup bool // a second factor is enrolled for this pairing
// QuarantinedUntil: this machine refuses the Host's writes until then
// (quarantine.go); Release takes the owner's code.
QuarantinedUntil string
Serving bool // its loopback control port answers — add-folder goes there
Stepup bool // a second factor is enrolled for this pairing
}
// uiEnrolView is an enrolment in flight. The SECRET IS NOT IN IT: the handle
// addresses it server-side and the QR route turns it into pixels. Key is filled
// only by the one route the person has to press a button to reach.
type uiEnrolView struct {
Handle string
Host string
Key string
Handle string
Machine bool
Host string
Key string
}
// uiFolderCheck is one typed path resolved against the real filesystem, before
@@ -258,6 +273,15 @@ type uiSetupView struct {
AnchorOwner string
AnchorRequired bool
Owner string
// OwnerCode: the machine owner's code is set up (stepup_machine.go).
OwnerCode bool
// Whole: the bastion runs for the whole machine, so the owner acts that
// need the machine's administrator can be done from this page.
Whole bool
// Limited: opened from the owner's own account (consolelane.go). Setting
// up the code, connecting a Host, the Host in charge and folder proposals
// are the administrator's there.
Limited bool
// ReadError is why the pairing list could not be read — see uiGiven's copy
// of this field. Returning the bare view here showed an empty setup page
@@ -291,6 +315,8 @@ func (s *uiSetup) view() uiSetupView {
if o := currentOwnerClaim(); o != nil {
v.Owner = o.Owner
}
v.OwnerCode = stepupEnrolled(existingMachineFactorDir())
v.Whole = machineAuthorityHeld()
cfgs, err := loadPairings()
if err != nil {
v.ReadError = err.Error()
@@ -303,6 +329,9 @@ func (s *uiSetup) view() uiSetupView {
Active: c.active(),
IsAnchor: v.AnchorHost != "" && c.HostName == v.AnchorHost,
}
if until := quarantinedUntil(c.HostName, time.Now()); !until.IsZero() {
h.QuarantinedUntil = until.Local().Format("Jan 2 15:04")
}
// dcnDir() is MkdirAll-on-read (config.go:115), so asking a PENDING
// pairing whether it has a factor would create its state directory —
// the side effect runPairing guards every one of these behind
@@ -325,6 +354,7 @@ func (s *uiSetup) view() uiSetupView {
// into that one because the view model differs; TestTheSetupPageIsHeldToTheSame
// HeadersAsTheStatusPage is what stops the two drifting apart.
func (s *uiSetup) render(w http.ResponseWriter, name string, v uiSetupView) {
v.Limited = limitedWriter(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; form-action 'self'")
@@ -522,6 +552,10 @@ func (s *uiSetup) stepupStart(w http.ResponseWriter, r *http.Request) {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if r.FormValue("target") == "machine" {
s.stepupStartMachine(w, r)
return
}
cfg, err := setupHost(r.FormValue("host"))
if err != nil {
s.flash(w, err.Error(), "bad")
@@ -664,6 +698,10 @@ func (s *uiSetup) stepupConfirm(w http.ResponseWriter, r *http.Request) {
s.flash(w, "Type a code from the authenticator you just scanned with.", "warn")
return
}
if e.Machine {
s.stepupConfirmMachine(w, handle, e, code)
return
}
cfg, err := setupHost(e.Host)
if err != nil {
s.flash(w, err.Error(), "bad")
@@ -951,6 +989,10 @@ func (s *uiSetup) ownerName(w http.ResponseWriter, r *http.Request) {
}
cmdView := func(c uiCommand) { s.render(w, "owner-cmd", uiSetupView{Cmd: &c}) }
name := strings.TrimSpace(r.FormValue("owner"))
if machineAuthorityHeld() && stepupEnrolled(existingMachineFactorDir()) {
s.ownerNameHere(w, name, r.FormValue("code"))
return
}
// claimNameOK (anchor.go:121) is the real rule: it is what stops a name
// being read as more than one field of the signed input.
if err := claimNameOK(name); err != nil {
@@ -988,3 +1030,62 @@ func (s *uiSetup) ownerName(w http.ResponseWriter, r *http.Request) {
func uiSetupTemplates() []string {
return []string{"setup-page", "setup", "folder-check", "stepup-key", "anchor-cmd", "owner-cmd"}
}
// ── the owner's own code ────────────────────────────────────────────────────
// stepupStartMachine is stepupStart for the machine owner's code: the same
// in-memory secret, QR and confirm-before-write, written to the owner's
// factor directory (stepup_machine.go) instead of a pairing's. It needs no
// Host, which is the point: on a machine with none it is the only code there is.
func (s *uiSetup) stepupStartMachine(w http.ResponseWriter, r *http.Request) {
if stepupEnrolled(existingMachineFactorDir()) && r.FormValue("replace") == "" {
s.flash(w, "Your code is already set up on this machine. Tick “replace it” to set up a new one — "+
"the authenticator set up now stops working.", "warn")
return
}
var secret [20]byte
if _, err := rand.Read(secret[:]); err != nil {
s.flash(w, "Could not generate a secret on this machine: "+err.Error(), "bad")
return
}
b32 := strings.TrimRight(base32.StdEncoding.EncodeToString(secret[:]), "=")
hn, _ := os.Hostname()
// The same URI the CLI builds for --machine (stepup.go cmdStepupEnroll).
uri := "otpauth://totp/" + url.PathEscape("Privatae bastion:"+hn) + "?secret=" + b32 +
"&issuer=" + url.QueryEscape("Privatae bastion") + "&digits=6&period=30"
handle, err := s.enrols.put(&uiEnrol{Machine: true, SecretB32: b32, URI: uri, Expires: uiNow().Add(uiEnrolTTL)})
if err != nil {
s.flash(w, "Could not start setting up your code: "+err.Error(), "bad")
return
}
v := s.view()
v.Enrol = &uiEnrolView{Handle: handle, Machine: true}
v.Flash = "Scan the square with your authenticator app, then type a code from it below. " +
"Nothing is written to this machine until that code matches."
v.FlashClass = "ok"
s.render(w, "setup", v)
}
func (s *uiSetup) stepupConfirmMachine(w http.ResponseWriter, handle string, e *uiEnrol, code string) {
if stepupMatchedCounter(e.SecretB32, code, time.Now()) < 0 {
s.flash(w, "That code did not match — nothing was set up. Check the app is showing a code for "+
"“Privatae bastion”, and try the next one.", "bad")
return
}
dir, err := machineFactorDir()
if err != nil {
s.flash(w, "Could not open this machine's place for your code: "+err.Error(), "bad")
return
}
if err := stepupSave(dir, &stepupFile{
SecretB32: e.SecretB32,
EnrolledAt: time.Now().UTC().Format(time.RFC3339),
LastCounter: -1,
}); err != nil {
s.flash(w, "Could not write your code to this machine: "+err.Error(), "bad")
return
}
s.enrols.drop(handle)
s.flash(w, "Your code is set up. Stopping an agent, marking one as yours, and answering what is "+
"asked of this machine now take a code from that app.", "ok")
}