console: PATCH gate hashes the password; record replies carry gated, never the hash
The instance PATCH gains a `gate` field: absent leaves the host's gate alone, "" opens it, anything else is the new password — checked against gate.CheckPassword's bounds and hashed with gate.Hash before it reaches the record, so the plaintext lives only in the handler's stack frame and the refusal names the rule, never the value. Every API answer that carries a record now goes through a view that shadows Record.Gate for encoding/json and puts `gated: bool` in its place: the list, the GET, the create reply, the update reply and both channel-move replies. The bcrypt hash never leaves the console.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+173
−13
internal/console/apiinstances.go+66 −6internal/console/apiinstances_test.go+105 −5internal/console/channelmove.go+2 −2
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 6ce77a0c8..92a74efc4 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -38,6 +38,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/gate" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -168,6 +169,11 @@ type instanceListRecord struct { |
| // Record.Region, which is the REQUESTED placement code ("" = |
| // unconstrained, "fleet/<name>"), and absent for a host no file names. |
| ServedBy []servedBy `json:"served_by,omitempty"` |
| + // HiddenGate shadows Record.Gate for encoding/json (a shallower field of |
| + // the same name wins), so the bcrypt hash never leaves the console; it |
| + // is always "" and omitted. Gated is what a member may know. |
| + HiddenGate string `json:"gate,omitempty"` |
| + Gated bool `json:"gated"` |
| } |
| // instanceHealth computes each record host's health word the same way the |
| @@ -409,7 +415,7 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) { |
| } |
| matched = append(matched, instanceListRecord{Record: rec, |
| StagedVersion: join.versions[rec.Host], Health: h, Upstreams: join.upstreams[rec.Host], |
| - ServedBy: served[rec.Host]}) |
| + ServedBy: served[rec.Host], Gated: rec.Gate != ""}) |
| } |
| total := len(matched) |
| // provision.ListRecords returns records host-sorted and nothing above |
| @@ -469,7 +475,7 @@ func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| writeJSON(w, http.StatusOK, instanceGetRecord{Record: *rec, |
| - ServedBy: loadEdgesJoin(ctx, s.cfg.Releases)[host]}) |
| + ServedBy: loadEdgesJoin(ctx, s.cfg.Releases)[host], Gated: rec.Gate != ""}) |
| } |
| // instanceGetRecord is the single-record answer: the stored record plus the |
| @@ -480,6 +486,25 @@ func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) { |
| type instanceGetRecord struct { |
| provision.Record |
| ServedBy []servedBy `json:"served_by,omitempty"` |
| + // HiddenGate shadows Record.Gate for encoding/json (a shallower field of |
| + // the same name wins), so the bcrypt hash never leaves the console; it |
| + // is always "" and omitted. Gated is what a member may know. |
| + HiddenGate string `json:"gate,omitempty"` |
| + Gated bool `json:"gated"` |
| +} |
| + |
| +// apiRecordView is a record as the write doors answer it: the whole record |
| +// with the gate hash shadowed and a gated flag in its place. Every handler |
| +// that used to writeJSON a bare record answers through this, so there is one |
| +// place — not five — where "the hash never leaves" has to hold. |
| +type apiRecordView struct { |
| + provision.Record |
| + HiddenGate string `json:"gate,omitempty"` |
| + Gated bool `json:"gated"` |
| +} |
| + |
| +func recordView(rec provision.Record) apiRecordView { |
| + return apiRecordView{Record: rec, Gated: rec.Gate != ""} |
| } |
| // handleAPIInstanceCreate is the point of the feature: one create-only |
| @@ -754,7 +779,7 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| // a box-consumed write (carlos-deploy §3): announce it, and the instance |
| // is serving seconds after signup instead of on the hour. |
| s.bumpSerial(ctx) |
| - writeJSON(w, http.StatusCreated, rec) |
| + writeJSON(w, http.StatusCreated, recordView(rec)) |
| } |
| // instanceChannel answers "which channel is this record born on" for a |
| @@ -973,13 +998,18 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| Probe json.RawMessage `json:"probe"` |
| Steering *string `json:"steering"` |
| Environment *string `json:"environment"` |
| + // Gate is a *string like Steering: absent leaves the gate alone, |
| + // "" opens the host, anything else is the new password — hashed |
| + // here, never stored or echoed (spec §5.2). The plaintext crosses |
| + // the wire once, over TLS, the same as a sign-in. |
| + Gate *string `json:"gate"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| } |
| - if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil { |
| + if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil { |
| jsonError(w, http.StatusBadRequest, |
| - "nothing to update — send addrs, probe, steering, environment, or a combination") |
| + "nothing to update — send addrs, probe, steering, environment, gate, or a combination") |
| return |
| } |
| var probe *provision.Health |
| @@ -1026,6 +1056,30 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| if req.Environment != nil { |
| rec.Environment = strings.TrimSpace(*req.Environment) |
| } |
| + // The gate is the one field this door does not store as it was sent: the |
| + // record carries a bcrypt hash and nothing else, so the plaintext lives |
| + // only in this stack frame. Not trimmed either — a password's leading and |
| + // trailing spaces are part of it, and the login POST compares the bytes |
| + // the member typed. |
| + if req.Gate != nil { |
| + if *req.Gate == "" { |
| + rec.Gate = "" |
| + } else { |
| + // CheckPassword's sentences name the rule and never the value: |
| + // nothing below (and nothing logged) can echo the password back. |
| + if err := gate.CheckPassword(*req.Gate); err != nil { |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| + h, err := gate.Hash(*req.Gate) |
| + if err != nil { |
| + log.Printf("console: api instances update %s/%s/%s: hash gate: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusInternalServerError, "could not set the password — try again") |
| + return |
| + } |
| + rec.Gate = h |
| + } |
| + } |
| // The same door the create path and the box's reconcile pass check |
| // against, so a shape that could not have been created cannot be patched |
| // into existence either — and the caller gets ValidateShape's own |
| @@ -1109,6 +1163,12 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| if req.Steering != nil { |
| changed = append(changed, "steering") |
| } |
| + // "gate" names that the host's password was set or cleared, never which |
| + // it was: the audit log answers "did anyone change the gate on this host |
| + // last week?" without becoming a place a password can be read. |
| + if req.Gate != nil { |
| + changed = append(changed, "gate") |
| + } |
| s.audit(acct, app, principal, "instance-update", |
| "updated instance "+host+" ("+strings.Join(changed, ", ")+") (cli)") |
| // Strictly after the write landed, and exactly once: the record is what a |
| @@ -1116,7 +1176,7 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| // so this announcement is what makes the new set live in seconds instead |
| // of on the hour. |
| s.bumpSerial(ctx) |
| - writeJSON(w, http.StatusOK, rec) |
| + writeJSON(w, http.StatusOK, recordView(*rec)) |
| } |
| // handleAPIInstanceDelete retires an instance's route authorization. The |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index c6c623267..dad6e8472 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -854,8 +854,8 @@ func TestAPIInstanceListHealthAndFilters(t *testing.T) { |
| } |
| // 5) ADDITIVE: the OLD client's shape still parses, and every field a |
| - // record already had is byte-identical. The only key the wire gained |
| - // is "health". |
| + // record already had is byte-identical. The keys the wire gained are |
| + // "health", "staged_version" and "gated". |
| _, raw := get(t, "") |
| var old struct { |
| Instances []provision.Record `json:"instances"` |
| @@ -896,8 +896,11 @@ func TestAPIInstanceListHealthAndFilters(t *testing.T) { |
| } |
| } |
| sort.Strings(extra) |
| - if !slices.Equal(extra, []string{"health", "staged_version"}) { |
| - t.Errorf("%s carries extra keys %v, want exactly health and staged_version", rec.Host, extra) |
| + // "gated" joined health and staged_version when the gate landed: the |
| + // hash itself is shadowed out of the wire (recordView), so what a |
| + // list gains is the one bit a member may know. |
| + if !slices.Equal(extra, []string{"gated", "health", "staged_version"}) { |
| + t.Errorf("%s carries extra keys %v, want exactly gated, health and staged_version", rec.Host, extra) |
| } |
| } |
| } |
| @@ -1457,7 +1460,7 @@ func TestAPIInstanceUpdateAuditNamesChangedFields(t *testing.T) { |
| // Multi-field case: a patch touching addrs AND steering together names |
| // both, in the order the request struct declares them (addrs, probe, |
| - // steering) — not just one of the two, and not a generic "updated". |
| + // steering, gate) — not just one of the two, and not a generic "updated". |
| if status, _, out := rawReq(t, http.MethodPatch, pu, token, |
| `{"addrs":["127.0.0.1:7002"],"steering":""}`); status != http.StatusOK { |
| t.Fatalf("patch addrs+steering = %d %s, want 200", status, out) |
| @@ -2409,3 +2412,100 @@ func TestAPIInstanceCreateStaticInAPoolDoesNotSteer(t *testing.T) { |
| t.Fatalf("stored Steering = %q, want empty — steering requires the shared pool", stored.Steering) |
| } |
| } |
| + |
| +// TestAPIInstanceUpdateGateRoundTrip: the PATCH takes a PLAINTEXT password, |
| +// hashes it here (spec §5.2) and answers with a record carrying "gated":true |
| +// and no "gate" key at all — the bcrypt hash is written to the bucket and |
| +// never leaves the console again, not on the write reply, not on the list, |
| +// not on the GET. Clearing is `{"gate":""}`, which opens the host and flips |
| +// the same flag back. |
| +func TestAPIInstanceUpdateGateRoundTrip(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-gate-round") |
| + enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"gate.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + pu := updateURL(fx, "bab", "titogo", "gate.go.tito.io") |
| + |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"gate":"correct horse"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("patch gate = %d %s, want 200", status, out) |
| + } |
| + var echoed map[string]any |
| + if err := json.Unmarshal([]byte(out), &echoed); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, leaked := echoed["gate"]; leaked { |
| + t.Fatalf("patch reply carries the gate hash: %s", out) |
| + } |
| + if echoed["gated"] != true { |
| + t.Fatalf("patch reply gated = %v, want true", echoed["gated"]) |
| + } |
| + stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(stored.Gate, "$2a$10$") { |
| + t.Fatalf("stored gate = %q, want a bcrypt hash", stored.Gate) |
| + } |
| + if strings.Contains(stored.Gate, "correct horse") { |
| + t.Fatal("stored gate carries the plaintext") |
| + } |
| + if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance gate.go.tito.io (gate) (cli)" { |
| + t.Fatalf("audit detail = %q", detail) |
| + } |
| + |
| + // The list and the GET shadow the hash the same way. |
| + for _, path := range []string{u, pu} { |
| + _, _, body := rawReq(t, http.MethodGet, path, token, "") |
| + if strings.Contains(body, "$2a$") { |
| + t.Fatalf("GET %s leaks the hash: %s", path, body) |
| + } |
| + if !strings.Contains(body, `"gated":true`) { |
| + t.Fatalf("GET %s lacks gated:true: %s", path, body) |
| + } |
| + } |
| + |
| + status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"gate":""}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("patch clear = %d %s", status, out) |
| + } |
| + stored, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io") |
| + if stored.Gate != "" { |
| + t.Fatalf("stored gate after clear = %q", stored.Gate) |
| + } |
| + _, _, body := rawReq(t, http.MethodGet, pu, token, "") |
| + if !strings.Contains(body, `"gated":false`) { |
| + t.Fatalf("GET after clear: %s", body) |
| + } |
| +} |
| + |
| +// TestAPIInstanceUpdateGateRefusals: gate.CheckPassword's two bounds are this |
| +// door's own 400, in the caller's terms — and the refusal never echoes the |
| +// password back, because a 400 body is exactly where a rejected password |
| +// would otherwise land in a log or a terminal's scrollback. |
| +func TestAPIInstanceUpdateGateRefusals(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-gate-refuse") |
| + enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"gr.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + pu := updateURL(fx, "bab", "titogo", "gr.go.tito.io") |
| + for _, body := range []string{`{"gate":"short7!"}`, `{"gate":"` + strings.Repeat("x", 73) + `"}`} { |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, body) |
| + if status != http.StatusBadRequest || !strings.Contains(out, "password") { |
| + t.Fatalf("%s -> %d %s, want 400 naming the password rule", body, status, out) |
| + } |
| + if strings.Contains(out, "short7!") || strings.Contains(out, "xxxxxxxx") { |
| + t.Fatalf("refusal echoes the password: %s", out) |
| + } |
| + } |
| + // The empty-body rail names gate among the patchable fields. |
| + if _, _, out := rawReq(t, http.MethodPatch, pu, token, `{}`); !strings.Contains(out, "gate") { |
| + t.Fatalf("empty-body refusal should name gate: %s", out) |
| + } |
| +} |
diff --git a/internal/console/channelmove.go b/internal/console/channelmove.go| index d5cbffd15..9dfd53c52 100644 |
| --- a/internal/console/channelmove.go |
| +++ b/internal/console/channelmove.go |
| @@ -140,7 +140,7 @@ func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request |
| // Nothing changed: no write, no serial bump, no audit line. A serial |
| // that names no change sends every box in the fleet converging for |
| // nothing, and buries the tenant's audit log under no-ops. |
| - writeJSON(w, http.StatusOK, rec) |
| + writeJSON(w, http.StatusOK, recordView(*rec)) |
| return |
| } |
| pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| @@ -189,7 +189,7 @@ func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request |
| // column from this record, so announcing it is what makes the move take |
| // seconds instead of an hour. |
| s.bumpSerial(ctx) |
| - writeJSON(w, http.StatusOK, rec) |
| + writeJSON(w, http.StatusOK, recordView(*rec)) |
| } |
| // isCanaryChannel reports whether a name is in the reserved canary |