1461 lines
51 KiB
Go
1461 lines
51 KiB
Go
package main
|
|
|
|
// Second factor ON THE BOX: the host must not be able to elevate or bypass
|
|
// the second factor. The host's own TOTP secret lives in the pod;
|
|
// code running in the pod could compute a valid code, and a pod that merely
|
|
// FORWARDS a code the user typed could attach it to something else. So both
|
|
// the secret AND the confirmation live here:
|
|
//
|
|
// pod → stepup_request <purpose> <details> (over the mgmt lane; no code)
|
|
// box: checks that details really produce that purpose, stores the request
|
|
// PENDING, shows nothing to the pod but an id
|
|
// user, ON THIS MACHINE: `privatae-bastion approvals` prints each pending
|
|
// request in plain words (folder, ops, moves); `privatae-bastion approve`
|
|
// asks for the code from the authenticator enrolled HERE, verifies +
|
|
// burns it, and records {approval id, purpose}
|
|
// pod → stepup_status <request id> → approved → puts `approval` into the
|
|
// terms, signs, syncs / applies
|
|
// box: on dcn_grant_sync / dcn_plan_apply, recomputes the purpose from the
|
|
// signed terms and requires a matching, live approval
|
|
//
|
|
// The pod never sees the code and never chooses what the user confirms: the
|
|
// words on the screen come from details the box has verified against the
|
|
// purpose it will later enforce. A compromised pod can still SIGN anything —
|
|
// it holds the entity key — but this box will not enforce it.
|
|
//
|
|
// The purpose is a plain string both sides derive the same way (never JSON
|
|
// canonicalization across two languages):
|
|
// store grant: dcn_store_grant|<server>|<root>|<mask sorted ,>|<excludes sorted ,>|<sweep>|<sensitive>|ttl:<days>
|
|
// move plan: dcn_move_plan|<store_id>|<plan_id>|<root>|<archive>|<sha256 of "from\tto\n" lines>
|
|
//
|
|
// Not enrolled here = today's behavior (the pod verifies its own factor).
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
stepupDigits = 6
|
|
stepupPeriod = 30
|
|
stepupDrift = 1 // ± windows for clock skew (RFC 6238 §5.2)
|
|
stepupApprovalTTL = 24 * time.Hour
|
|
stepupRequestTTL = 24 * time.Hour
|
|
)
|
|
|
|
var stepupMu sync.Mutex // serializes verify+burn, approvals and requests per process
|
|
|
|
type stepupFile struct {
|
|
SecretB32 string `json:"secret_b32"`
|
|
EnrolledAt string `json:"enrolled_at,omitempty"`
|
|
LastCounter int64 `json:"last_counter"`
|
|
}
|
|
|
|
type stepupApproval struct {
|
|
Purpose string `json:"purpose"`
|
|
At string `json:"at"`
|
|
Used bool `json:"used"`
|
|
}
|
|
|
|
// stepupRequest is what the pod asked the user to confirm, in the box's words.
|
|
type stepupRequest struct {
|
|
Purpose string `json:"purpose"`
|
|
Details map[string]any `json:"details"`
|
|
At string `json:"at"`
|
|
State string `json:"state"` // pending | approved | denied
|
|
Approval string `json:"approval,omitempty"`
|
|
// Delivered: the approval id was handed to the pod once. The same ask
|
|
// again after that is a new act (a new request), not a spent id.
|
|
Delivered bool `json:"delivered,omitempty"`
|
|
// DeniedAt: when the owner refused it. A refusal blocks the same ask for
|
|
// stepupRequestTTL from HERE, not from the ask: denied 23 h after it was
|
|
// asked, it used to block for one hour.
|
|
DeniedAt string `json:"denied_at,omitempty"`
|
|
}
|
|
|
|
// errRefusedRecently: the owner refused this same ask within the last day.
|
|
var errRefusedRecently = errors.New("you refused this on this machine; it cannot be asked again for 24h")
|
|
|
|
func stepupPath(dir string) string { return filepath.Join(dir, "stepup.json") }
|
|
func approvalsPath(dir string) string { return filepath.Join(dir, "approvals.json") }
|
|
func requestsPath(dir string) string { return filepath.Join(dir, "stepup_requests.json") }
|
|
|
|
func stepupLoad(dir string) (*stepupFile, error) {
|
|
raw, err := os.ReadFile(stepupPath(dir))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var f stepupFile
|
|
if err := json.Unmarshal(raw, &f); err != nil {
|
|
return nil, err
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
func stepupSave(dir string, f *stepupFile) error {
|
|
raw, err := json.MarshalIndent(f, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return atomicWrite(stepupPath(dir), raw)
|
|
}
|
|
|
|
// stepupEnrolled reports whether this pairing holds a CONFIRMED second factor.
|
|
func stepupEnrolled(dir string) bool {
|
|
if dir == "" {
|
|
return false
|
|
}
|
|
f, err := stepupLoad(dir)
|
|
return err == nil && f.SecretB32 != "" && f.EnrolledAt != ""
|
|
}
|
|
|
|
// ── TOTP (RFC 6238, HMAC-SHA1) — mirrors the host's implementation exactly ──
|
|
|
|
func hotp(secret []byte, counter int64) string {
|
|
mac := hmac.New(sha1.New, secret)
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], uint64(counter))
|
|
mac.Write(b[:])
|
|
h := mac.Sum(nil)
|
|
off := h[len(h)-1] & 0x0f
|
|
code := (binary.BigEndian.Uint32(h[off:off+4]) & 0x7fffffff) % 1000000
|
|
return fmt.Sprintf("%06d", code)
|
|
}
|
|
|
|
func b32decode(s string) ([]byte, error) {
|
|
s = strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(s), " ", ""))
|
|
if pad := len(s) % 8; pad != 0 {
|
|
s += strings.Repeat("=", 8-pad)
|
|
}
|
|
return base32.StdEncoding.DecodeString(s)
|
|
}
|
|
|
|
// stepupMatchedCounter returns the window a code matches (±drift), or -1.
|
|
func stepupMatchedCounter(secretB32, code string, at time.Time) int64 {
|
|
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
|
if len(code) != stepupDigits {
|
|
return -1
|
|
}
|
|
for _, c := range code {
|
|
if c < '0' || c > '9' {
|
|
return -1
|
|
}
|
|
}
|
|
raw, err := b32decode(secretB32)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
now := at.Unix() / stepupPeriod
|
|
hit := int64(-1)
|
|
for w := int64(-stepupDrift); w <= stepupDrift; w++ {
|
|
if hmac.Equal([]byte(hotp(raw, now+w)), []byte(code)) {
|
|
hit = now + w // no early return: keep the timing flat
|
|
}
|
|
}
|
|
return hit
|
|
}
|
|
|
|
// stepupVerifyAndBurn accepts a code once: the matched window must be newer
|
|
// than the last accepted one, and is recorded.
|
|
func stepupVerifyAndBurn(dir, code string, at time.Time) (int64, error) {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
f, err := stepupLoad(dir)
|
|
if err != nil || f.SecretB32 == "" || f.EnrolledAt == "" {
|
|
return -1, fmt.Errorf("no second factor enrolled on this machine (privatae-bastion stepup-enroll)")
|
|
}
|
|
c := stepupMatchedCounter(f.SecretB32, code, at)
|
|
if c < 0 {
|
|
return -1, fmt.Errorf("code refused")
|
|
}
|
|
if c <= f.LastCounter {
|
|
return -1, fmt.Errorf("code already used")
|
|
}
|
|
f.LastCounter = c
|
|
if err := stepupSave(dir, f); err != nil {
|
|
return -1, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// ── purposes — the same strings the host builds ──
|
|
|
|
// purposeBad reports a component that would collide with the purpose's own
|
|
// delimiters — "|" between fields, "," between list entries, "\t"/"\n" between
|
|
// move parts. Such terms are refused outright (a pod could otherwise sign
|
|
// excludes [".env,.ssh"] under an approval the user read as two patterns).
|
|
func purposeBad(fields []string, entries []string) error {
|
|
for _, f := range fields {
|
|
if strings.ContainsAny(f, "|\t\n") {
|
|
return fmt.Errorf("a term contains a reserved character (|)")
|
|
}
|
|
}
|
|
for _, e := range entries {
|
|
if strings.ContainsAny(e, "|,\t\n") {
|
|
return fmt.Errorf("a list entry contains a reserved character (| or ,)")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func purposeForGrant(t *dcnTerms) (string, error) {
|
|
mask := append([]string(nil), t.Mask...)
|
|
excl := append([]string(nil), t.Excludes...)
|
|
sort.Strings(mask)
|
|
sort.Strings(excl)
|
|
if err := purposeBad([]string{t.Server, t.Root, t.Sweep}, append(append([]string(nil), mask...), excl...)); err != nil {
|
|
return "", err
|
|
}
|
|
sens := "false"
|
|
if t.Sensitive {
|
|
sens = "true"
|
|
}
|
|
return "dcn_store_grant|" + t.Server + "|" + t.Root + "|" + strings.Join(mask, ",") + "|" +
|
|
strings.Join(excl, ",") + "|" + t.Sweep + "|" + sens + "|ttl:" + strconv.Itoa(t.TTLDays), nil
|
|
}
|
|
|
|
func purposeForPlan(p *dcnPlanTerms) (string, error) {
|
|
if err := purposeBad([]string{p.Root, p.Archive}, nil); err != nil {
|
|
return "", err
|
|
}
|
|
h := sha256.New()
|
|
for _, m := range p.Moves {
|
|
if strings.ContainsAny(m.From+m.To, "\t\n") {
|
|
return "", fmt.Errorf("a move path contains a reserved character")
|
|
}
|
|
h.Write([]byte(m.From + "\t" + m.To + "\n"))
|
|
}
|
|
return fmt.Sprintf("dcn_move_plan|%d|%d|%s|%s|%s", p.StoreID, p.PlanID, p.Root, p.Archive,
|
|
hex.EncodeToString(h.Sum(nil))), nil
|
|
}
|
|
|
|
// criticalActionTerms is a platform CRITICAL_ACTION awaiting the user's second
|
|
// factor — capability_grant, constitutional_*, frontend_deploy, entity_*.
|
|
//
|
|
// Unlike the dcn kinds, the box is NOT the enforcer here: the platform executes
|
|
// the action after it sees an approval. So the box's whole job is to make the
|
|
// approval bind to one specific action, and to show the user the same words the
|
|
// platform will act on. Summary is hashed rather than carried in the purpose
|
|
// because it is free text and the purpose is a |-delimited field list.
|
|
type criticalActionTerms struct {
|
|
Action string // the CRITICAL_ACTIONS type
|
|
EntityID int64 // the entity the action runs against
|
|
ActionID string // outbox action id
|
|
Target string // short subject of the action (host name, capability, route)
|
|
Summary string // the one-line the user reads — bound by hash below
|
|
}
|
|
|
|
func purposeForCriticalAction(t *criticalActionTerms) (string, error) {
|
|
if err := purposeBad([]string{t.Action, t.ActionID, t.Target}, nil); err != nil {
|
|
return "", err
|
|
}
|
|
if t.Action == "" || t.ActionID == "" {
|
|
return "", fmt.Errorf("a critical action needs an action type and an action id")
|
|
}
|
|
// Every field the user reads is bound: the three plain ones directly, the
|
|
// free-text summary by digest. A pod that shows one sentence and asks the
|
|
// platform to run something else cannot produce a matching purpose.
|
|
h := sha256.Sum256([]byte(t.Summary))
|
|
return fmt.Sprintf("critical_action|%s|%d|%s|%s|%s", t.Action, t.EntityID, t.ActionID,
|
|
t.Target, hex.EncodeToString(h[:])), nil
|
|
}
|
|
|
|
// ── the caps ceiling for a step-up ─────────────────────────────────────────
|
|
|
|
// stepupOpFamily is the capability family a step-up op must be gated on.
|
|
//
|
|
// A step-up is not a capability of its own. It is the approval step of another
|
|
// one, and purposeFromDetails below already enumerates exactly which: a DCN
|
|
// store grant or move plan, a root shell, a model download, or a platform
|
|
// critical action. So the ceiling for raising one is the ceiling of the thing
|
|
// being approved — a pairing that may not do DCN work may not ask the user to
|
|
// authorize DCN work either.
|
|
//
|
|
// Gating all of them as ONE family would be wrong in both directions, which is
|
|
// why this exists rather than a line in opFamily. Too loose: a pairing granted
|
|
// "dcn" could raise a ROOT SHELL prompt, which is the session family's to give.
|
|
// Too tight, and this is the one that would have broken the fleet: a pairing
|
|
// granted "session" and not "dcn" could no longer raise the step-up that
|
|
// session:root REQUIRES (session.go:412-417), and one granted "models" could no
|
|
// longer raise the one model:pull requires (modelswap.go:796). The
|
|
// families are independently grantable on purpose — see the note in opFamily
|
|
// about why "model:" was kept out of "inference" — so folding the approval step
|
|
// into any single one of them silently disarms the others.
|
|
//
|
|
// critical_action is the one kind that maps to capsFamilyNone rather than to a
|
|
// family, and deliberately: the box is NOT the enforcer there (see
|
|
// criticalActionTerms above — the platform executes once it sees an approval),
|
|
// so there is no capability of this machine's being authorized and no family
|
|
// that could honestly be named. Holding a verified grant at all, which
|
|
// capsAllowIn has already checked by this point, is the whole ceiling.
|
|
//
|
|
// An input this cannot classify also returns capsFamilyNone, and that is safe
|
|
// rather than a hole: the classifiable set here is exactly purposeFromDetails's
|
|
// set, and stepupNewRequest calls purposeFromDetails FIRST and refuses anything
|
|
// outside it, so an unclassifiable request creates nothing. Deferring to the
|
|
// handler keeps its own error messages ("bad base64", "unknown request kind")
|
|
// instead of turning every malformed frame into a caps refusal that says
|
|
// nothing about what was actually wrong.
|
|
func stepupOpFamily(op, dir string, args []string) string {
|
|
switch op {
|
|
case "stepup_request":
|
|
if len(args) < 2 {
|
|
return capsFamilyNone
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[1]))
|
|
if err != nil {
|
|
return capsFamilyNone
|
|
}
|
|
var details map[string]any
|
|
if err := json.Unmarshal(raw, &details); err != nil {
|
|
return capsFamilyNone
|
|
}
|
|
return stepupDetailsFamily(details)
|
|
case "stepup_status":
|
|
// The ceiling is the one the request was RAISED under, read back from
|
|
// this pairing's own store. An id that is not there is left to the
|
|
// handler, which answers "expired" — refusing instead would turn this
|
|
// into an oracle for which ids exist.
|
|
if dir == "" || len(args) < 1 {
|
|
return capsFamilyNone
|
|
}
|
|
stepupMu.Lock()
|
|
r, ok := requestsLoad(dir)[strings.TrimSpace(args[0])]
|
|
stepupMu.Unlock()
|
|
if !ok {
|
|
return capsFamilyNone
|
|
}
|
|
return stepupDetailsFamily(r.Details)
|
|
}
|
|
return capsFamilyNone
|
|
}
|
|
|
|
// stepupDetailsFamily is stepupKindFamily for a whole request: a `mgmt_op`
|
|
// ask is gated on the family of the op it would approve (writes.go), read
|
|
// from the details — an op in no family stays in no family and capsAllowIn
|
|
// refuses it, and a directive (self-update, contain, mirror) is the anchor's
|
|
// alone, so a member asking for its approval is refused at the ask.
|
|
func stepupDetailsFamily(details map[string]any) string {
|
|
kind, _ := details["kind"].(string)
|
|
if kind != "mgmt_op" {
|
|
return stepupKindFamily(kind)
|
|
}
|
|
op, _ := details["op"].(string)
|
|
if isDirectiveOp(op) {
|
|
return capsFamilyAnchor
|
|
}
|
|
return opFamily(op)
|
|
}
|
|
|
|
// stepupKindFamily maps a request kind to the family of the op it authorizes.
|
|
// The kinds are purposeFromDetails's; an unknown one gets capsFamilyNone, which
|
|
// the handler then refuses on its own as "unknown request kind".
|
|
func stepupKindFamily(kind string) string {
|
|
switch kind {
|
|
case "dcn_store_grant", "dcn_move_plan":
|
|
return "dcn"
|
|
case "session_root":
|
|
return "session"
|
|
case "model_pull":
|
|
return modelGateOpFamily
|
|
case reachKind:
|
|
return "services"
|
|
case coderGrantKind:
|
|
return "harness"
|
|
case opKindRun, opKindGrant:
|
|
return opsFamily
|
|
}
|
|
return capsFamilyNone
|
|
}
|
|
|
|
// purposeFromDetails rebuilds the purpose from the plain details the pod sent —
|
|
// the words the user will read are the words the box will enforce, or the
|
|
// request is refused.
|
|
func purposeFromDetails(d map[string]any) (string, error) {
|
|
strs := func(v any) []string {
|
|
var out []string
|
|
if l, ok := v.([]any); ok {
|
|
for _, x := range l {
|
|
if s, ok := x.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
str := func(k string) string { s, _ := d[k].(string); return s }
|
|
num := func(k string) int64 {
|
|
switch v := d[k].(type) {
|
|
case float64:
|
|
return int64(v)
|
|
case string:
|
|
n, _ := strconv.ParseInt(v, 10, 64)
|
|
return n
|
|
}
|
|
return 0
|
|
}
|
|
switch str("kind") {
|
|
case localStopKind:
|
|
return purposeForLocalStop(int(num("pid")), str("signature"))
|
|
case "dcn_store_grant":
|
|
sens, _ := d["sensitive"].(bool)
|
|
return purposeForGrant(&dcnTerms{Server: str("server"), Root: str("root"), Mask: strs(d["mask"]),
|
|
Excludes: strs(d["excludes"]), Sweep: str("sweep"), Sensitive: sens, TTLDays: int(num("ttl_days"))})
|
|
case "dcn_move_plan":
|
|
var moves []dcnMove
|
|
if l, ok := d["moves"].([]any); ok {
|
|
for _, x := range l {
|
|
if m, ok := x.(map[string]any); ok {
|
|
from, _ := m["from"].(string)
|
|
to, _ := m["to"].(string)
|
|
moves = append(moves, dcnMove{From: from, To: to})
|
|
}
|
|
}
|
|
}
|
|
if len(moves) == 0 || len(moves) > dcnPlanMaxMoves {
|
|
return "", fmt.Errorf("plan details carry no moves")
|
|
}
|
|
return purposeForPlan(&dcnPlanTerms{StoreID: num("store_id"), PlanID: num("plan_id"),
|
|
Root: str("root"), Archive: str("archive"), Moves: moves})
|
|
case "session_root":
|
|
return purposeForSessionRoot(str("server"), str("session_id"))
|
|
case reachKind:
|
|
var mirrors []mirrorSource
|
|
if l, ok := d["mirrors"].([]any); ok {
|
|
for _, x := range l {
|
|
if m, ok := x.(map[string]any); ok {
|
|
u, _ := m["url"].(string)
|
|
r, _ := m["repo"].(string)
|
|
mirrors = append(mirrors, mirrorSource{URL: u, Repo: r})
|
|
}
|
|
}
|
|
}
|
|
return purposeForReach(&reachTerms{Server: str("server"), Slot: str("slot"),
|
|
Subject: str("subject"), TTLDays: int(num("ttl_days")), Mirrors: mirrors})
|
|
case coderGrantKind:
|
|
return purposeForCoderGrant(&coderTerms{Server: str("server"), Subject: str("subject"),
|
|
Engines: strs(d["engines"]), Max: int(num("max")), TTLDays: int(num("ttl_days"))})
|
|
case "critical_action":
|
|
return purposeForCriticalAction(&criticalActionTerms{
|
|
Action: str("action"), EntityID: num("entity_id"), ActionID: str("action_id"),
|
|
Target: str("target"), Summary: str("summary")})
|
|
case "model_pull":
|
|
// See modelswap.go. The byte ceiling is part of the purpose, so the
|
|
// number the user reads below is the number the download is held to.
|
|
return purposeForModelPull(str("model"), str("role"), num("max_bytes"), str("replaces"))
|
|
case opKindRun, opKindGrant:
|
|
// An op the host wrote (ops.go): the details ARE the terms' fields,
|
|
// and the purpose is the one the box recomputes at apply / install.
|
|
t := &opTerms{Kind: str("kind"), Op: str("op"), Version: str("version"), SHA256: str("sha256"),
|
|
Server: str("server"), Privilege: str("privilege"), Params: str("params"), Bounds: str("bounds"),
|
|
Changes: str("changes_sha256"), Expires: str("expires"), Uses: int(num("uses"))}
|
|
if t.Kind == opKindRun {
|
|
return purposeForOpRun(t)
|
|
}
|
|
return purposeForOpGrant(t)
|
|
case "mgmt_op":
|
|
// One run of a write on this machine (writes.go): the op, this
|
|
// machine, and the sha256 of exactly the args it will run with. The
|
|
// server is checked against THIS box here rather than only at run
|
|
// time, so a request for another machine is refused at the ask.
|
|
op, server := str("op"), str("server")
|
|
if op == "" || strings.ContainsAny(op, "|\n") {
|
|
return "", fmt.Errorf("mgmt_op details name no op")
|
|
}
|
|
if server == "" || strings.ContainsAny(server, "|\n ") {
|
|
return "", fmt.Errorf("mgmt_op details name no server")
|
|
}
|
|
var args []string
|
|
if _, ok := d["args"]; ok {
|
|
args = strs(d["args"])
|
|
}
|
|
return mgmtOpPurpose(op, server, args), nil
|
|
}
|
|
return "", fmt.Errorf("unknown request kind")
|
|
}
|
|
|
|
// argWordsMax is how much of one argument the card prints whole.
|
|
const argWordsMax = 2000
|
|
|
|
// argWords is one argument as the person reads it: whole up to argWordsMax
|
|
// bytes, then its full length and sha256 so the part they cannot see is at
|
|
// least named.
|
|
func argWords(a string) string {
|
|
if len(a) <= argWordsMax {
|
|
return a
|
|
}
|
|
sum := sha256.Sum256([]byte(a))
|
|
return a[:argWordsMax] + fmt.Sprintf(" …[%d bytes in all, sha256 %s]", len(a), hex.EncodeToString(sum[:8]))
|
|
}
|
|
|
|
// describeRequest renders a request in the user's words — from details the box
|
|
// has already verified against the purpose it will enforce.
|
|
// clean strips control characters from pod-supplied text before it is printed —
|
|
// a note with an embedded newline must not be able to fake a "never:" line.
|
|
func clean(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
// C0, DEL and C1 (0x80-0x9F — U+009B is a terminal control prefix that
|
|
// could redraw what the person is reading).
|
|
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
|
|
return ' '
|
|
}
|
|
return r
|
|
}, s)
|
|
}
|
|
|
|
func describeRequest(r stepupRequest) string {
|
|
d := r.Details
|
|
str := func(k string) string { s, _ := d[k].(string); return clean(s) }
|
|
strs := func(k string) []string {
|
|
var out []string
|
|
if l, ok := d[k].([]any); ok {
|
|
for _, x := range l {
|
|
if s, ok := x.(string); ok {
|
|
out = append(out, clean(s))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
switch str("kind") {
|
|
case localStopKind:
|
|
out := "Stop " + describeStopTarget(d) + " on this machine, and every process it started."
|
|
if by := str("asked_by"); by != "" {
|
|
out += " Asked from " + by + "."
|
|
}
|
|
return out
|
|
case "dcn_store_grant":
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Your host asks to reach the folder %s on %s\n", str("root"), str("server"))
|
|
fmt.Fprintf(&b, " allowed: %s\n", strings.Join(strs("mask"), ", "))
|
|
if ex := strs("excludes"); len(ex) > 0 {
|
|
fmt.Fprintf(&b, " never: %s\n", strings.Join(ex, ", "))
|
|
}
|
|
if sw := str("sweep"); sw != "" {
|
|
fmt.Fprintf(&b, " index refresh: %s\n", sw)
|
|
}
|
|
if ttl, ok := d["ttl_days"].(float64); ok && ttl > 0 {
|
|
fmt.Fprintf(&b, " for: %d days\n", int(ttl))
|
|
}
|
|
if n := str("display_name"); n != "" {
|
|
fmt.Fprintf(&b, " shown as: %s\n", n)
|
|
}
|
|
// The pod's note is not shown: free text nothing binds, beside terms
|
|
// the signature does bind, is the one thing this card must not carry.
|
|
b.WriteString(" Your files stay here; the host gets an index and reads on request.")
|
|
return b.String()
|
|
case "dcn_move_plan":
|
|
var b strings.Builder
|
|
moves, _ := d["moves"].([]any)
|
|
archive := str("archive")
|
|
into := 0
|
|
for _, x := range moves {
|
|
if m, ok := x.(map[string]any); ok {
|
|
to, _ := m["to"].(string)
|
|
if archive != "" && (to == archive || strings.HasPrefix(to, archive+"/")) {
|
|
into++
|
|
}
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "Your host asks to MOVE %d item(s) inside %s", len(moves), str("root"))
|
|
if t := str("title"); t != "" {
|
|
fmt.Fprintf(&b, " (%s)", t)
|
|
}
|
|
b.WriteString("\n")
|
|
if archive != "" {
|
|
fmt.Fprintf(&b, " %d go into the archive folder %s/ — nothing is deleted; you empty it by hand\n", into, archive)
|
|
}
|
|
for i, x := range moves {
|
|
if i == 12 {
|
|
fmt.Fprintf(&b, " … and %d more\n", len(moves)-12)
|
|
break
|
|
}
|
|
if m, ok := x.(map[string]any); ok {
|
|
from, _ := m["from"].(string)
|
|
to, _ := m["to"].(string)
|
|
fmt.Fprintf(&b, " %s → %s\n", clean(from), clean(to))
|
|
}
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
case reachKind:
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "The host %s asks to reach the server %s on this machine (%s)\n",
|
|
clean(str("subject")), clean(str("slot")), clean(str("server")))
|
|
if ttl, ok := d["ttl_days"].(float64); ok && ttl > 0 {
|
|
fmt.Fprintf(&b, " for: %d days\n", int(ttl))
|
|
}
|
|
if l, ok := d["mirrors"].([]any); ok && len(l) > 0 {
|
|
b.WriteString(" and lets THIS machine fetch, over your own connection, into that server:\n")
|
|
for _, x := range l {
|
|
if m, ok := x.(map[string]any); ok {
|
|
u, _ := m["url"].(string)
|
|
r, _ := m["repo"].(string)
|
|
fmt.Fprintf(&b, " %s → %s\n", clean(u), clean(r))
|
|
}
|
|
}
|
|
}
|
|
b.WriteString(" The server has no network; this lets that host alone reach it over its tunnel.")
|
|
return b.String()
|
|
case "session_root":
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Your host asks to open a ROOT shell on this machine (%s)\n", str("server"))
|
|
fmt.Fprintf(&b, " session: %s\n", str("session_id"))
|
|
if n := str("note"); n != "" {
|
|
fmt.Fprintf(&b, " why: %s\n", n)
|
|
}
|
|
b.WriteString(" Approving opens ONE root terminal, recorded to a transcript here. If you did not ask for it, deny.")
|
|
return b.String()
|
|
case "critical_action":
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Your host asks you to APPROVE a critical action: %s\n", str("action"))
|
|
if t := str("target"); t != "" {
|
|
fmt.Fprintf(&b, " on: %s\n", t)
|
|
}
|
|
if s := str("summary"); s != "" {
|
|
fmt.Fprintf(&b, " %s\n", s)
|
|
}
|
|
// Said plainly, because this is the one kind the box does not enforce:
|
|
// approving here lets the PLATFORM run it. The user should treat an
|
|
// action they did not ask for as the signal it is.
|
|
b.WriteString(" Approving lets the platform run this now. If you did not ask for it, deny.")
|
|
return b.String()
|
|
case "model_pull":
|
|
// Everything printed here is bound into the purpose
|
|
// (purposeForModelPull), so the words the user reads are the words the
|
|
// box enforces — including the size, which is a CEILING the download is
|
|
// stopped at, not an estimate.
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Your host asks to DOWNLOAD a model onto this machine: %s\n", str("model"))
|
|
role := str("role")
|
|
if role == "embed" {
|
|
role = "embedding (searching your own words)"
|
|
} else if role == "generate" {
|
|
role = "text generation"
|
|
}
|
|
fmt.Fprintf(&b, " for: %s\n", role)
|
|
if n, ok := d["max_bytes"].(float64); ok && n > 0 {
|
|
fmt.Fprintf(&b, " size: up to %s, written to this machine's disk\n", modelBytesWords(int64(n)))
|
|
}
|
|
if r := str("replaces"); r != "" {
|
|
fmt.Fprintf(&b, " meant to replace: %s (still here afterwards — removing it is a separate ask)\n", r)
|
|
}
|
|
if n := str("note"); n != "" {
|
|
fmt.Fprintf(&b, " why: %s\n", n)
|
|
}
|
|
b.WriteString(" Approving downloads it ONCE, at that size or less. If you did not ask for it, deny.")
|
|
return b.String()
|
|
case opKindRun, opKindGrant:
|
|
var b strings.Builder
|
|
once := str("kind") == opKindRun
|
|
if once {
|
|
fmt.Fprintf(&b, "Your host asks to run its op %s %s ONCE on %s\n", str("op"), str("version"), str("server"))
|
|
} else {
|
|
fmt.Fprintf(&b, "Your host asks to run its op %s %s on %s FROM NOW ON\n", str("op"), str("version"), str("server"))
|
|
}
|
|
if sm := str("summary"); sm != "" {
|
|
fmt.Fprintf(&b, " what it does: %s\n", sm)
|
|
}
|
|
fmt.Fprintf(&b, " as: %s\n", str("privilege"))
|
|
if eff := strs("effects"); len(eff) > 0 {
|
|
fmt.Fprintf(&b, " may change: %s\n", strings.Join(eff, ", "))
|
|
}
|
|
if once {
|
|
fmt.Fprintf(&b, " with: %s\n", argWords(str("params")))
|
|
if d := str("dry_run"); d != "" {
|
|
fmt.Fprintf(&b, " the dry run showed:\n%s\n", argWords(d))
|
|
}
|
|
} else {
|
|
fmt.Fprintf(&b, " within: %s\n", argWords(str("bounds")))
|
|
if n, _ := d["uses"].(float64); n > 0 {
|
|
fmt.Fprintf(&b, " up to %d runs\n", int(n))
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, " until: %s\n", str("expires"))
|
|
fmt.Fprintf(&b, " identity: sha256 %s\n", str("sha256"))
|
|
if once {
|
|
fmt.Fprintf(&b, " the changes above, as a whole: sha256 %s\n", str("changes_sha256"))
|
|
b.WriteString(" Approving applies exactly what the dry run showed, once — any other change lands nothing. If you did not ask for it, deny.")
|
|
} else {
|
|
b.WriteString(" Approving lets the host run it within these bounds until then, without asking again. If you did not ask for it, deny.")
|
|
}
|
|
return b.String()
|
|
case "mgmt_op":
|
|
// Every argument, whole, one per line: what the person reads IS what
|
|
// is bound. A long one is shown to argWordsMax bytes and then named by
|
|
// its length and digest, so a harmless start cannot hide a tail. The
|
|
// pod's note is deliberately NOT shown: it is free text nothing binds,
|
|
// and a sentence beside the arguments that the arguments do not carry
|
|
// is the one thing this card must not have.
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Your host asks to run %s on %s\n", str("op"), str("server"))
|
|
args := strs("args")
|
|
if len(args) == 0 {
|
|
b.WriteString(" with no arguments\n")
|
|
}
|
|
for i, a := range args {
|
|
fmt.Fprintf(&b, " arg %d: %s\n", i+1, argWords(a))
|
|
}
|
|
b.WriteString(" Approving runs it ONCE, with exactly these arguments. If you did not ask for it, deny.")
|
|
return b.String()
|
|
}
|
|
return "(unknown request)"
|
|
}
|
|
|
|
// ── approvals — live 24h, bound to a purpose ──
|
|
|
|
func approvalsLoad(dir string) map[string]stepupApproval {
|
|
out := map[string]stepupApproval{}
|
|
raw, err := os.ReadFile(approvalsPath(dir))
|
|
if err == nil {
|
|
_ = json.Unmarshal(raw, &out)
|
|
}
|
|
for id, a := range out { // forget expired ones
|
|
if t, err := time.Parse(time.RFC3339, a.At); err != nil || time.Since(t) > stepupApprovalTTL {
|
|
delete(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func approvalsSave(dir string, m map[string]stepupApproval) error {
|
|
raw, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return atomicWrite(approvalsPath(dir), raw)
|
|
}
|
|
|
|
func newID() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b[:]), nil
|
|
}
|
|
|
|
// stepupIssueApproval verifies the code and records an approval for purpose.
|
|
// The code is spent only if the approval lands (stepupIssueLocked).
|
|
func stepupIssueApproval(dir, code, purpose string) (string, error) {
|
|
if strings.TrimSpace(purpose) == "" {
|
|
return "", fmt.Errorf("purpose required")
|
|
}
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
return stepupIssueLocked(dir, code, purpose, nil)
|
|
}
|
|
|
|
// stepupLandingHook runs inside stepupApproveRequest after the approval is
|
|
// saved and before the request is read again, so the suite can answer the
|
|
// request from "another process" at exactly that point. Nothing outside tests
|
|
// sets it.
|
|
var stepupLandingHook func()
|
|
|
|
// stepupIssueLocked is the code check and the approval, under stepupMu (the
|
|
// caller holds it). A CODE IS SPENT ONLY WHEN THE APPROVAL LANDS. It is
|
|
// recorded as spent BEFORE the approval exists, so no crash can ever leave an
|
|
// approval standing on a code that still works; and if anything after that
|
|
// fails (the approval cannot be saved, or land refuses), the approval is
|
|
// withdrawn and the code is given back, unless something newer was spent
|
|
// meanwhile (another process). land, when not nil, finishes the act the
|
|
// approval is for; its error withdraws it.
|
|
func stepupIssueLocked(dir, code, purpose string, land func(approval string) error) (string, error) {
|
|
f, err := stepupLoad(dir)
|
|
if err != nil || f.SecretB32 == "" || f.EnrolledAt == "" {
|
|
return "", fmt.Errorf("no second factor enrolled on this machine (privatae-bastion stepup-enroll)")
|
|
}
|
|
c := stepupMatchedCounter(f.SecretB32, code, time.Now())
|
|
if c < 0 {
|
|
return "", fmt.Errorf("code refused")
|
|
}
|
|
if c <= f.LastCounter {
|
|
return "", fmt.Errorf("code already used")
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
prev := f.LastCounter
|
|
f.LastCounter = c
|
|
if err := stepupSave(dir, f); err != nil {
|
|
return "", err
|
|
}
|
|
giveBack := func() {
|
|
if cur, err := stepupLoad(dir); err == nil && cur.LastCounter == c {
|
|
cur.LastCounter = prev
|
|
_ = stepupSave(dir, cur)
|
|
}
|
|
}
|
|
m := approvalsLoad(dir)
|
|
m[id] = stepupApproval{Purpose: purpose, At: time.Now().UTC().Format(time.RFC3339)}
|
|
if err := approvalsSave(dir, m); err != nil {
|
|
giveBack()
|
|
return "", err
|
|
}
|
|
if land != nil {
|
|
if err := land(id); err != nil {
|
|
am := approvalsLoad(dir)
|
|
delete(am, id)
|
|
_ = approvalsSave(dir, am)
|
|
giveBack()
|
|
return "", err
|
|
}
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// stepupCheckApproval — is `id` a live, unused approval for exactly this purpose?
|
|
func stepupCheckApproval(dir, id, purpose string) error {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
a, ok := approvalsLoad(dir)[id]
|
|
if id == "" || !ok {
|
|
return fmt.Errorf("this machine requires your approval for this: none was given here (privatae-bastion approvals)")
|
|
}
|
|
if a.Used {
|
|
return fmt.Errorf("this approval was already used")
|
|
}
|
|
if a.Purpose != purpose {
|
|
return fmt.Errorf("approval is for something else (purpose mismatch)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stepupConsumeApproval is stepupCheckApproval and the burn under ONE lock:
|
|
// the approval is marked used and saved before the caller acts, so two
|
|
// workers handed the same id cannot both pass. A store that cannot be written
|
|
// refuses, the same way the device ledger does (device.go).
|
|
func stepupConsumeApproval(dir, id, purpose string) error {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
m := approvalsLoad(dir)
|
|
a, ok := m[id]
|
|
if id == "" || !ok {
|
|
return fmt.Errorf("this machine requires your approval for this: none was given here (privatae-bastion approvals)")
|
|
}
|
|
if a.Used {
|
|
return fmt.Errorf("this approval was already used")
|
|
}
|
|
if a.Purpose != purpose {
|
|
return fmt.Errorf("approval is for something else (purpose mismatch)")
|
|
}
|
|
a.Used = true
|
|
m[id] = a
|
|
if err := approvalsSave(dir, m); err != nil {
|
|
return fmt.Errorf("could not record this approval's use (%v); refused rather than reusable", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stepupMarkUsed burns an approval after the action it covered succeeded (a
|
|
// plan apply, or the gated install of a grant — see handleDcnGrantSync).
|
|
func stepupMarkUsed(dir, id string) {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
m := approvalsLoad(dir)
|
|
if a, ok := m[id]; ok {
|
|
a.Used = true
|
|
m[id] = a
|
|
_ = approvalsSave(dir, m)
|
|
}
|
|
}
|
|
|
|
// stepupGate is the wall grant-sync and plan-apply call. Nothing to do on a
|
|
// LOCAL box with no factor enrolled (the pairing is the authorization on a
|
|
// person's own machine); on an anchored box, or once a factor is enrolled, the
|
|
// terms' approval must check out — from this console or the person's device
|
|
// (device.go boxApproval). "Unenrolled passes" is gone for anchored boxes:
|
|
// they refuse, naming the fix.
|
|
func stepupGate(dir, approval, purpose string) error {
|
|
if !approvalRequired(dir) {
|
|
return nil
|
|
}
|
|
return boxApproval(dir, approval, purpose)
|
|
}
|
|
|
|
// ── requests — what the user is asked to confirm, kept here, shown here ──
|
|
|
|
func requestsLoad(dir string) map[string]stepupRequest {
|
|
out := map[string]stepupRequest{}
|
|
raw, err := os.ReadFile(requestsPath(dir))
|
|
if err == nil {
|
|
_ = json.Unmarshal(raw, &out)
|
|
}
|
|
for id, r := range out {
|
|
since := r.At
|
|
if r.State == "denied" && r.DeniedAt != "" {
|
|
since = r.DeniedAt
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, since); err != nil || time.Since(t) > stepupRequestTTL {
|
|
delete(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func requestsSave(dir string, m map[string]stepupRequest) error {
|
|
raw, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return atomicWrite(requestsPath(dir), raw)
|
|
}
|
|
|
|
// stepupNewRequest records a pending request after proving the details match
|
|
// the purpose. Returns the existing pending id for a repeated identical ask.
|
|
func stepupNewRequest(dir, purpose string, details map[string]any) (string, error) {
|
|
got, err := purposeFromDetails(details)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// A mgmt_op ask names the machine it is for; on THIS box's console it has
|
|
// to be this box (the device, which files the same details for another
|
|
// machine, checks nothing here — device.go). Checked at the ask, not in
|
|
// purposeFromDetails, for exactly that reason.
|
|
if kind, _ := details["kind"].(string); kind == "mgmt_op" {
|
|
server, _ := details["server"].(string)
|
|
if hn, _ := os.Hostname(); hn != "" && server != hn {
|
|
return "", fmt.Errorf("this request is for server '%s', this box is '%s'", server, hn)
|
|
}
|
|
}
|
|
if got != purpose {
|
|
return "", fmt.Errorf("request details do not match the purpose (the words would not be what is enforced)")
|
|
}
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
m := requestsLoad(dir)
|
|
// A refusal wins over anything else for the same purpose, whatever order
|
|
// the map is walked in: two passes, deny first.
|
|
for _, r := range m {
|
|
if r.Purpose == purpose && r.State == "denied" {
|
|
return "", errRefusedRecently
|
|
}
|
|
}
|
|
localStop := false
|
|
if kind, _ := details["kind"].(string); kind == localStopKind {
|
|
localStop = true
|
|
}
|
|
asker, _ := details["asked_by_account"].(string)
|
|
for id, r := range m {
|
|
if localStop {
|
|
// A local stop belongs to the account that asked it, which alone
|
|
// can follow it (askstatus.go): handing another account the same
|
|
// id told it that someone else had asked, and gave it an id it
|
|
// could not follow. An approved stop was acted on already, so the
|
|
// same stop asked again is a new request (it used to return the
|
|
// approved id for a day, so a stop that failed could not be asked
|
|
// again). The 24 h block after a deny, above, holds for every
|
|
// account: the owner refused that stop, not that asker.
|
|
if theirs, _ := r.Details["asked_by_account"].(string); r.Purpose == purpose &&
|
|
r.State == "pending" && theirs == asker {
|
|
return id, nil
|
|
}
|
|
continue
|
|
}
|
|
if r.Purpose == purpose && r.State == "pending" {
|
|
return id, nil
|
|
}
|
|
// An approved ask whose id was never handed out is the tap the pod
|
|
// missed while it was away: the same request, answered approved by
|
|
// the handler. Once delivered, the same ask is a new act.
|
|
if r.Purpose == purpose && r.State == "approved" && !r.Delivered {
|
|
return id, nil
|
|
}
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
m[id] = stepupRequest{Purpose: purpose, Details: details,
|
|
At: time.Now().UTC().Format(time.RFC3339), State: "pending"}
|
|
if err := requestsSave(dir, m); err != nil {
|
|
return "", err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// stepupApproveRequest — the user, on this machine, with the code: verify,
|
|
// issue the approval, mark the request approved, all under one hold of
|
|
// stepupMu. The code is spent only if the request ends up approved: a request
|
|
// answered meanwhile, or a store that cannot be written, gives it back
|
|
// (stepupIssueLocked).
|
|
func stepupApproveRequest(dir, id, code string) (string, error) {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
r, ok := requestsLoad(dir)[id]
|
|
if !ok || r.State != "pending" {
|
|
return "", fmt.Errorf("no pending request %s", id)
|
|
}
|
|
approval, err := stepupIssueLocked(dir, code, r.Purpose, func(approval string) error {
|
|
if stepupLandingHook != nil {
|
|
stepupLandingHook()
|
|
}
|
|
// Read again: another process (the service, an Administrator
|
|
// terminal) shares these files but not this lock, and may have
|
|
// answered the request since. Denied meanwhile: the approval must
|
|
// not stand.
|
|
m := requestsLoad(dir)
|
|
cur := m[id]
|
|
if cur.State != "pending" {
|
|
return fmt.Errorf("request is no longer pending (%s)", cur.State)
|
|
}
|
|
cur.State, cur.Approval = "approved", approval
|
|
m[id] = cur
|
|
return requestsSave(dir, m)
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// A code was accepted, on whichever lane: the tray's count of wrong codes
|
|
// starts again (approvalpipe.go).
|
|
pipeThrottleReset(dir)
|
|
dcnAuditWith(dir, "stepup_approve", 0, "", "approved: "+r.Purpose, askPartiesOf(r.Details))
|
|
return approval, nil
|
|
}
|
|
|
|
func stepupDenyRequest(dir, id string) error {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
m := requestsLoad(dir)
|
|
r, ok := m[id]
|
|
if !ok {
|
|
return fmt.Errorf("no request %s", id)
|
|
}
|
|
if r.State != "pending" {
|
|
return fmt.Errorf("request is %s, not pending", r.State)
|
|
}
|
|
r.State, r.DeniedAt = "denied", time.Now().UTC().Format(time.RFC3339)
|
|
m[id] = r
|
|
dcnAuditWith(dir, "stepup_deny", 0, "", "denied: "+r.Purpose, askPartiesOf(r.Details))
|
|
if err := requestsSave(dir, m); err != nil {
|
|
return err
|
|
}
|
|
// An agent on this machine asked, and the owner said no: a strike
|
|
// against the account that asked (askquarantine.go). Only a local stop
|
|
// is an agent's ask; a Host's request never strikes an account here.
|
|
if kind, _ := r.Details["kind"].(string); kind == localStopKind {
|
|
if acct, _ := r.Details["asked_by_account"].(string); acct != "" {
|
|
askStrike(dir, acct, time.Now())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── the mgmt ops: stepup_request <purpose> <details_b64>, stepup_status <id> ──
|
|
|
|
func handleStepupRequest(ws *websocket.Conn, dir string, args []string) {
|
|
if dir == "" || len(args) < 2 {
|
|
sendMgmt(ws, 'R', []byte("stepup_request: need <purpose> <details_b64>\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
if !stepupEnrolled(dir) {
|
|
sendMgmt(ws, 'R', []byte("no second factor enrolled on this machine (privatae-bastion stepup-enroll)\n"))
|
|
sendMgmtExit(ws, 126)
|
|
return
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[1]))
|
|
if err != nil {
|
|
sendMgmt(ws, 'R', []byte("bad base64\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
var details map[string]any
|
|
if err := json.Unmarshal(raw, &details); err != nil || details == nil {
|
|
sendMgmt(ws, 'R', []byte("bad details json\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
hostAskDetails(details)
|
|
id, err := stepupNewRequest(dir, args[0], details)
|
|
if err != nil {
|
|
dcnAudit(dir, "stepup_request", 0, "", "refused: "+err.Error())
|
|
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
|
|
sendMgmtExit(ws, 126)
|
|
return
|
|
}
|
|
if approval, ok := stepupHandOut(dir, id); ok {
|
|
out, _ := json.Marshal(map[string]any{"state": "approved", "request": id, "approval": approval})
|
|
sendMgmt(ws, 'O', out)
|
|
sendMgmtExit(ws, 0)
|
|
return
|
|
}
|
|
dcnAudit(dir, "stepup_request", 0, "", "pending: "+args[0])
|
|
out, _ := json.Marshal(map[string]any{"state": "pending", "request": id,
|
|
"next": "on that machine: privatae-bastion approvals, then privatae-bastion approve " + id[:8]})
|
|
sendMgmt(ws, 'O', out)
|
|
sendMgmtExit(ws, 0)
|
|
}
|
|
|
|
// stepupHandOut gives an approved request's approval id to the pod ONCE
|
|
// (marks the request delivered). ("", false) for anything else.
|
|
func stepupHandOut(dir, id string) (string, bool) {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
m := requestsLoad(dir)
|
|
r, ok := m[id]
|
|
if !ok || r.State != "approved" || r.Delivered || r.Approval == "" {
|
|
return "", false
|
|
}
|
|
r.Delivered = true
|
|
m[id] = r
|
|
if err := requestsSave(dir, m); err != nil {
|
|
return "", false
|
|
}
|
|
return r.Approval, true
|
|
}
|
|
|
|
func handleStepupStatus(ws *websocket.Conn, dir string, args []string) {
|
|
if dir == "" || len(args) < 1 {
|
|
sendMgmt(ws, 'R', []byte("stepup_status: need <request_id>\n"))
|
|
sendMgmtExit(ws, 1)
|
|
return
|
|
}
|
|
stepupMu.Lock()
|
|
r, ok := requestsLoad(dir)[strings.TrimSpace(args[0])]
|
|
stepupMu.Unlock()
|
|
if !ok {
|
|
out, _ := json.Marshal(map[string]any{"state": "expired"})
|
|
sendMgmt(ws, 'O', out)
|
|
sendMgmtExit(ws, 0)
|
|
return
|
|
}
|
|
res := map[string]any{"state": r.State}
|
|
if r.State == "approved" {
|
|
if approval, ok := stepupHandOut(dir, strings.TrimSpace(args[0])); ok {
|
|
res["approval"] = approval
|
|
} else {
|
|
res["state"] = "delivered" // handed out already; not repeated
|
|
}
|
|
}
|
|
out, _ := json.Marshal(res)
|
|
sendMgmt(ws, 'O', out)
|
|
sendMgmtExit(ws, 0)
|
|
}
|
|
|
|
// ── CLI ──
|
|
|
|
func stepupPairing(host string) (*Config, error) {
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(cfgs) == 0 {
|
|
return nil, fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
|
|
}
|
|
switch {
|
|
case host != "":
|
|
cfg, err := findPairing(cfgs, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg == nil {
|
|
return nil, fmt.Errorf("no pairing matches --host '%s' — paired hosts:\n %s",
|
|
host, strings.Join(hostNames(cfgs), "\n "))
|
|
}
|
|
return cfg, nil
|
|
case len(cfgs) == 1:
|
|
return cfgs[0], nil
|
|
}
|
|
return nil, fmt.Errorf("%d hosts are paired — say which one with --host <host>\npaired hosts:\n %s",
|
|
len(cfgs), strings.Join(hostNames(cfgs), "\n "))
|
|
}
|
|
|
|
// enrolConsole and enrolServiceState are seams so the suite can drive
|
|
// --console-only on any machine; nothing outside tests assigns them.
|
|
var (
|
|
enrolConsole = stdioIsConsole
|
|
enrolServiceState = func() string {
|
|
return enrolServiceStateRefusal(runtime.GOOS, systemService.installed, systemService.authority, systemService.err)
|
|
}
|
|
)
|
|
|
|
// enrolServiceStateRefusal says why this process's state is not the system
|
|
// service's, or "" when it is. --console-only enrols only into the service's
|
|
// state: anywhere else, the secret would sit in a profile the signed-in
|
|
// person's own processes can read.
|
|
//
|
|
// - Windows and macOS: the service is installed and this process administers
|
|
// the machine, which is when adoptSystemServiceState points the state
|
|
// directory at the service's (sysservice.go).
|
|
// - Linux: root, whose state the systemd unit reads (deploy/install.sh).
|
|
func enrolServiceStateRefusal(goos string, installed, authority bool, err error) string {
|
|
if err != nil {
|
|
return "cannot tell whether this machine runs the bastion as a system service (" + err.Error() + ")"
|
|
}
|
|
if !authority {
|
|
return "this process does not administer the machine, so its state is not the service's; " + adminHowToFor(goos)
|
|
}
|
|
if (goos == "windows" || goos == "darwin") && !installed {
|
|
return "the bastion is not installed as this machine's service yet (privatae-bastion service install)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// privatae-bastion stepup-enroll [--host <host>] [--rotate] [--console-only]
|
|
//
|
|
// --console-only is how an installer enrols in its one elevated step: the
|
|
// secret is shown only on the console this elevated process owns, never
|
|
// written to a file or a pipe its output may have been sent to, and saved only
|
|
// in the system service's state. Both are checked BEFORE a secret exists.
|
|
func cmdStepupEnroll(host string, machine, rotate, consoleOnly bool) error {
|
|
if consoleOnly {
|
|
if !enrolConsole() {
|
|
return fmt.Errorf("stepup-enroll --console-only shows the secret only on a console, and this one's input or output " +
|
|
"goes to a file or a pipe — nothing was enrolled; run it in a terminal window")
|
|
}
|
|
if why := enrolServiceState(); why != "" {
|
|
return fmt.Errorf("stepup-enroll --console-only enrols only into the system service's state: %s — nothing was enrolled", why)
|
|
}
|
|
}
|
|
t, err := resolveStepupTarget(host, machine)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := t.dir
|
|
if stepupEnrolled(dir) && !rotate {
|
|
fmt.Println("a second factor is already enrolled " + t.forWhom() + " (use --rotate to replace it)")
|
|
return nil
|
|
}
|
|
var secret [20]byte
|
|
if _, err := rand.Read(secret[:]); err != nil {
|
|
return err
|
|
}
|
|
b32 := strings.TrimRight(base32.StdEncoding.EncodeToString(secret[:]), "=")
|
|
hn, _ := os.Hostname()
|
|
issuer, label := "Privatae NAC", "Privatae NAC:"+hn+" for "+t.host
|
|
if t.machine() {
|
|
// The owner's own factor: named for the machine, not for a Host.
|
|
issuer, label = "Privatae bastion", "Privatae bastion:"+hn
|
|
}
|
|
uri := "otpauth://totp/" + url.PathEscape(label) + "?secret=" + b32 + "&issuer=" + url.QueryEscape(issuer) +
|
|
"&digits=6&period=30"
|
|
fmt.Println("Add this second factor to your authenticator app — it lives on THIS machine only:")
|
|
fmt.Println()
|
|
// Scan beats retype. The URI and the key stay printed below it: a QR is
|
|
// useless over a serial console, in a scrollback paste, or on a terminal
|
|
// without the block glyphs, and this is the one moment in the whole flow
|
|
// where being unable to enrol strands the user with no second factor.
|
|
if code, err := qrTerminal(uri); err == nil {
|
|
fmt.Print(code)
|
|
fmt.Println()
|
|
fmt.Println(" point your authenticator app at the square above")
|
|
fmt.Println()
|
|
}
|
|
fmt.Println(" or open this link: " + uri)
|
|
fmt.Println()
|
|
fmt.Println(" or enter the key by hand: " + b32)
|
|
fmt.Println()
|
|
fmt.Print("Enter a code from the app to confirm: ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
code, _ := reader.ReadString('\n')
|
|
if stepupMatchedCounter(b32, code, time.Now()) < 0 {
|
|
return fmt.Errorf("that code did not match — nothing enrolled; run the command again")
|
|
}
|
|
f := &stepupFile{SecretB32: b32, EnrolledAt: time.Now().UTC().Format(time.RFC3339), LastCounter: -1}
|
|
if err := stepupSave(dir, f); err != nil {
|
|
return err
|
|
}
|
|
if t.machine() {
|
|
// A new code at an administrator terminal is the person: the
|
|
// console's count of wrong codes starts again (machineCodeSpend).
|
|
stepupMu.Lock()
|
|
pipeThrottleReset(dir)
|
|
stepupMu.Unlock()
|
|
fmt.Println("enrolled. This is your code as the owner of this machine: what is asked of the machine itself,")
|
|
fmt.Println("rather than by a Host, waits for it. `privatae-bastion approvals" + t.flag() + "` shows what is waiting.")
|
|
return nil
|
|
}
|
|
fmt.Println("enrolled. From now on, your host can only reach a folder or move files on this machine after")
|
|
fmt.Println("you approve the request HERE: `privatae-bastion approvals` shows what it asks, `privatae-bastion approve` confirms it.")
|
|
return nil
|
|
}
|
|
|
|
func sortedPending(m map[string]stepupRequest) []string {
|
|
var ids []string
|
|
for id, r := range m {
|
|
if r.State == "pending" {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
|
|
return ids
|
|
}
|
|
|
|
// privatae-bastion approvals [--host <host>]
|
|
// approvalsRead is `approvals`: what waits for the code of one second factor.
|
|
type approvalsRead struct {
|
|
For string `json:"for"` // "for this machine", or a Host's
|
|
Host string `json:"host,omitempty"` // "" is the machine's owner
|
|
CodeSetUp bool `json:"code_set_up"`
|
|
Waiting []approvalWaiting `json:"waiting"`
|
|
}
|
|
|
|
type approvalWaiting struct {
|
|
ID string `json:"id"` // what approve and deny take
|
|
AskedAt string `json:"asked_at"`
|
|
Purpose string `json:"purpose"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
func readApprovals(o readOpts) (approvalsRead, error) {
|
|
t, err := resolveStepupTarget(o.Host, o.Machine)
|
|
if err != nil {
|
|
return approvalsRead{}, err
|
|
}
|
|
r := approvalsRead{For: t.forWhom(), Host: t.host, CodeSetUp: stepupEnrolled(t.dir), Waiting: []approvalWaiting{}}
|
|
if !r.CodeSetUp {
|
|
return r, nil
|
|
}
|
|
stepupMu.Lock()
|
|
m := requestsLoad(t.dir)
|
|
stepupMu.Unlock()
|
|
for _, id := range sortedPending(m) {
|
|
r.Waiting = append(r.Waiting, approvalWaiting{ID: id[:8], AskedAt: m[id].At, Purpose: m[id].Purpose,
|
|
Text: describeRequest(m[id])})
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func cmdStepupApprovals(host string, machine, asJSON bool) error {
|
|
r, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
|
|
if err != nil {
|
|
return laneRefusalError(strings.TrimSpace(stepupCommand("approvals", host, machine, "")), err)
|
|
}
|
|
if asJSON {
|
|
return printJSON(os.Stdout, r)
|
|
}
|
|
printApprovals(os.Stdout, r)
|
|
return nil
|
|
}
|
|
|
|
func printApprovals(w io.Writer, r approvalsRead) {
|
|
flag := stepupTarget{host: r.Host}.flag()
|
|
if !r.CodeSetUp {
|
|
fmt.Fprintln(w, "no second factor enrolled "+r.For+" — run `privatae-bastion stepup-enroll"+flag+"` first")
|
|
return
|
|
}
|
|
if len(r.Waiting) == 0 {
|
|
fmt.Fprintln(w, "nothing waiting for your approval "+r.For)
|
|
return
|
|
}
|
|
for _, q := range r.Waiting {
|
|
fmt.Fprintf(w, "[%s] asked %s\n%s\n\n", q.ID, q.AskedAt, q.Text)
|
|
}
|
|
fmt.Fprintln(w, "approve one: privatae-bastion approve"+flag+" <id> deny one: privatae-bastion deny"+flag+" <id>")
|
|
}
|
|
|
|
func findRequest(m map[string]stepupRequest, prefix string) (string, error) {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if prefix == "" {
|
|
return "", fmt.Errorf("which request? run `privatae-bastion approvals`")
|
|
}
|
|
var hit string
|
|
for id, r := range m {
|
|
if r.State == "pending" && strings.HasPrefix(id, prefix) {
|
|
if hit != "" {
|
|
return "", fmt.Errorf("'%s' matches more than one request; give more of the id", prefix)
|
|
}
|
|
hit = id
|
|
}
|
|
}
|
|
if hit == "" {
|
|
return "", fmt.Errorf("no pending request starts with '%s'", prefix)
|
|
}
|
|
return hit, nil
|
|
}
|
|
|
|
// privatae-bastion approve [--host <host> | --machine] <request id>
|
|
func cmdStepupApprove(host string, machine bool, prefix string) error {
|
|
if readsGoThroughLane() {
|
|
return ownerApproveViaLane(host, machine, prefix)
|
|
}
|
|
t, err := resolveStepupTarget(host, machine)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := t.dir
|
|
if !stepupEnrolled(dir) {
|
|
return fmt.Errorf("no second factor enrolled %s — run `privatae-bastion stepup-enroll%s` first", t.forWhom(), t.flag())
|
|
}
|
|
stepupMu.Lock()
|
|
m := requestsLoad(dir)
|
|
stepupMu.Unlock()
|
|
id, err := findRequest(m, prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(describeRequest(m[id]))
|
|
fmt.Println()
|
|
fmt.Print("Approve this? Enter the code from your authenticator (or leave empty to cancel): ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
code, _ := reader.ReadString('\n')
|
|
if strings.TrimSpace(code) == "" {
|
|
fmt.Println("cancelled — nothing approved")
|
|
return nil
|
|
}
|
|
if _, err := stepupApproveRequest(dir, id, code); err != nil {
|
|
return err
|
|
}
|
|
if t.machine() {
|
|
fmt.Println("approved.")
|
|
return nil
|
|
}
|
|
fmt.Println("approved. Your host picks this up on its next check-in (within a minute) — or click Approve again on your Host.")
|
|
return nil
|
|
}
|
|
|
|
// privatae-bastion deny [--host <host> | --machine] <request id>
|
|
func cmdStepupDeny(host string, machine bool, prefix string) error {
|
|
if readsGoThroughLane() {
|
|
done, err := actViaSock(agentAskRequest{Act: "deny", Host: host, Machine: machine, ID: prefix})
|
|
if err != nil {
|
|
return laneRefusalError(stepupCommand("deny", host, machine, prefix), err)
|
|
}
|
|
fmt.Println(done)
|
|
return nil
|
|
}
|
|
t, err := resolveStepupTarget(host, machine)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := t.dir
|
|
stepupMu.Lock()
|
|
m := requestsLoad(dir)
|
|
stepupMu.Unlock()
|
|
id, err := findRequest(m, prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := stepupDenyRequest(dir, id); err != nil {
|
|
return err
|
|
}
|
|
if t.machine() {
|
|
fmt.Println("denied.")
|
|
return nil
|
|
}
|
|
fmt.Println("denied — the host is told on its next check-in")
|
|
return nil
|
|
}
|