Files
privatae-bastion/client-go/device.go
T
Privatae 1176f7fd0b
ci / personal-data (push) Successful in 8s
ci / test (push) Successful in 7m17s
ci / fuzz (push) Successful in 6m33s
bastion 0.61.0
2026-11-02 06:20:42 +00:00

1075 lines
35 KiB
Go

package main
// device.go — the approval device, and the one check every write on a networked
// box runs.
//
// A write on a
// networked box needs an approval THIS box verified. Two ways to give one:
//
// - at this console, with the factor enrolled here (stepup.go — a box-issued
// approval id, single-use, bound to a purpose); or
// - from the person's OWN bastion, the approval device: a key held there,
// pinned on this box at anchoring with machine authority (pin-device), signs
// {purpose, nonce, expires, uses}. This box verifies the signature under a
// pinned device key, derives the purpose itself, refuses an expired or
// replayed one, and burns the nonce. The pod relays the request and the
// signed answer and can forge neither.
//
// Both land in approvalOK below, which is what the gates call. The person's own
// laptop's bastion is the device: it holds the key (device-init), shows what is
// being asked in the box's own words (device-approvals), and signs on a tap
// (device-approve). Pending asks reach it from the pod on its own mgmt lane
// (device_request / device_status).
//
// Every approval is bounded: expiry (a device approval lives at most
// deviceApprovalMaxTTL), uses (one), a nonce (never twice). "From now on" —
// a reusable, bounded op-grant — is rung 6's, and rides the same verification.
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
// The longest a device-signed approval may be valid for. One run, one tap,
// and the run happens as soon as the pod has the answer; an hour covers a
// slow relay many times over and keeps a leaked approval short-lived.
deviceApprovalMaxTTL = time.Hour
// How long a pending ask waits on the device before it lapses.
deviceRequestTTL = 24 * time.Hour
// deviceApprovalVersion is the format the signed bytes carry; a verifier
// refuses any other, so a future shape cannot be read as this one.
deviceApprovalVersion = 1
// deviceClockSkew is how far a governed box's clock may lag the device's
// before an approval signed for the full TTL reads as over-long here.
// Kerberos' default tolerance; a fleet box a minute behind was refusing
// every approval under the first cut's one-minute allowance.
deviceClockSkew = 5 * time.Minute
)
var deviceMu sync.Mutex
// ── keys pinned on THIS box (the verifier's side) ───────────────────────────
// devicePinned reports whether this box holds at least one device key.
func devicePinned() bool {
return len(deviceKeys()) > 0
}
// deviceKeys is the pinned device keys (b64 raw ed25519 public keys), from
// anchor.json — the same file, the same authority (machine authority at the
// box) that pins the anchor's verify key.
func deviceKeys() []string {
anchorMu.Lock()
defer anchorMu.Unlock()
f, err := loadAnchorFile()
if err != nil || f == nil {
return nil
}
return append([]string(nil), f.DeviceKeys...)
}
// secondFactorAvailable: something on this box can vouch for the person — a
// TOTP enrolled here for this pairing, or a device key pinned for the box.
// Without either, a write on a networked box is refused outright (never
// "unenrolled passes").
func secondFactorAvailable(dir string) bool {
return stepupEnrolled(dir) || devicePinned()
}
// pinDeviceKey adds a device key. Machine authority is the caller's to check
// (the CLI does); refuses a malformed key; idempotent for a key already held.
func pinDeviceKey(key string) error {
key = strings.TrimSpace(key)
pub, err := base64.StdEncoding.DecodeString(key)
if err != nil || len(pub) != ed25519.PublicKeySize {
return errors.New("bad device key (want a base64 raw ed25519 public key, as device-init prints)")
}
anchorMu.Lock()
defer anchorMu.Unlock()
f, err := loadAnchorFile()
if err != nil || f == nil {
return errors.New("box is not anchored — pin-device belongs to an anchored, networked box")
}
for _, k := range f.DeviceKeys {
if k == key {
return nil
}
}
f.DeviceKeys = append(f.DeviceKeys, key)
return saveAnchorFile(f)
}
func unpinDeviceKey(key string) error {
key = strings.TrimSpace(key)
anchorMu.Lock()
defer anchorMu.Unlock()
f, err := loadAnchorFile()
if err != nil || f == nil {
return errors.New("box is not anchored")
}
kept := f.DeviceKeys[:0]
found := false
for _, k := range f.DeviceKeys {
if k == key {
found = true
continue
}
kept = append(kept, k)
}
if !found {
return errors.New("that device key is not pinned here")
}
f.DeviceKeys = kept
return saveAnchorFile(f)
}
// ── the signed approval ──────────────────────────────────────────────────────
// deviceApproval is what the device signs. The canonical bytes are the fields
// joined by newlines in this order — the same on the signer and the verifier,
// never a JSON encoding of the struct.
type deviceApproval struct {
V int `json:"v"`
Purpose string `json:"purpose"`
Nonce string `json:"nonce"`
Expires string `json:"expires"`
Uses int `json:"uses"`
Key string `json:"key"`
Sig string `json:"sig"`
}
func (a *deviceApproval) canonical() []byte {
return []byte(strings.Join([]string{
fmt.Sprint(a.V), a.Purpose, a.Nonce, a.Expires, fmt.Sprint(a.Uses), a.Key,
}, "\n"))
}
// isDeviceApproval tells a device-signed approval (a JSON object with a
// signature) from a box-issued approval id (an opaque token).
func isDeviceApproval(s string) bool {
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "{") && strings.Contains(s, "\"sig\"")
}
// verifyDeviceApproval — the whole wall, in one place: format, a pinned key,
// the signature, THIS purpose, not expired and not too long-lived, one use,
// nonce never seen. Returns the parsed approval so the caller can burn it.
func verifyDeviceApproval(raw, purpose string, now time.Time) (*deviceApproval, error) {
deviceMu.Lock()
defer deviceMu.Unlock()
return verifyDeviceApprovalLocked(raw, purpose, now)
}
func verifyDeviceApprovalLocked(raw, purpose string, now time.Time) (*deviceApproval, error) {
var a deviceApproval
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &a); err != nil {
return nil, errors.New("approval is not readable")
}
if a.V != deviceApprovalVersion {
return nil, fmt.Errorf("approval format v%d is not one this box reads", a.V)
}
// The signed bytes are the fields joined by newlines, so a newline inside
// one would let the same signature be read with the line moved to the next
// field: an approval for "P\nX" with nonce N would also read as purpose P
// with nonce "X\nN". The purposes this box builds never hold one; refusing
// it here makes every signed approval read one way only.
if strings.ContainsAny(a.Purpose+a.Nonce, "\r\n") {
return nil, errors.New("approval has a line break inside a field")
}
pinned := false
for _, k := range deviceKeys() {
if k == a.Key {
pinned = true
break
}
}
if !pinned {
return nil, errors.New("approval is signed by a device this box has not pinned (pin-device, with machine authority)")
}
pub, err := base64.StdEncoding.DecodeString(a.Key)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, errors.New("bad device key")
}
sig, err := base64.StdEncoding.DecodeString(a.Sig)
if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), a.canonical(), sig) {
return nil, errors.New("approval signature does not verify")
}
if a.Purpose != purpose {
return nil, errors.New("approval is for something else (purpose mismatch)")
}
if a.Uses != 1 {
return nil, fmt.Errorf("approval claims %d uses; a device approval is one run", a.Uses)
}
exp, err := time.Parse(time.RFC3339, a.Expires)
if err != nil {
return nil, errors.New("approval has no readable expiry")
}
if !exp.After(now) {
return nil, errors.New("approval expired")
}
if exp.After(now.Add(deviceApprovalMaxTTL + deviceClockSkew)) {
return nil, fmt.Errorf("approval would live longer than %s; refused", deviceApprovalMaxTTL)
}
if len(a.Nonce) < 16 {
return nil, errors.New("approval nonce too short")
}
if _, seen := nonceLoad()[a.Nonce]; seen {
return nil, errors.New("approval already used (nonce seen)")
}
return &a, nil
}
// consumeDeviceApproval is verifyDeviceApproval and the burn as ONE step under
// the ledger lock: the nonce is recorded before the caller acts, so two
// workers handed the same approval cannot both pass (the check-then-burn gap
// the first cut had, with two management workers per pairing). A ledger that
// cannot be written refuses — an approval whose use cannot be recorded is
// reusable, and refusing is the only answer that keeps "single-use" true.
func consumeDeviceApproval(raw, purpose string, now time.Time) (*deviceApproval, error) {
deviceMu.Lock()
defer deviceMu.Unlock()
a, err := verifyDeviceApprovalLocked(raw, purpose, now)
if err != nil {
return nil, err
}
m := nonceLoad()
m[a.Nonce] = a.Expires
if err := nonceSave(m); err != nil {
return nil, fmt.Errorf("could not record this approval's use (%v); refused rather than reusable", err)
}
return a, nil
}
// ── the nonce ledger (box-level) ─────────────────────────────────────────────
func noncePath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "device_nonces.json"), nil
}
func nonceLoad() map[string]string {
out := map[string]string{}
p, err := noncePath()
if err != nil {
return out
}
raw, err := os.ReadFile(p)
if err == nil {
_ = json.Unmarshal(raw, &out)
}
// Prune what can no longer be replayed: a nonce whose approval has expired.
now := time.Now()
for n, exp := range out {
if t, err := time.Parse(time.RFC3339, exp); err != nil || !t.After(now) {
delete(out, n)
}
}
return out
}
func nonceSave(m map[string]string) error {
p, err := noncePath()
if err != nil {
return err
}
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return atomicWrite(p, raw)
}
// burnNonce records the nonce until its approval's expiry (after which a
// replay fails on expiry anyway).
func burnNonce(a *deviceApproval) {
deviceMu.Lock()
defer deviceMu.Unlock()
m := nonceLoad()
m[a.Nonce] = a.Expires
_ = nonceSave(m)
}
// ── the one check the gates call ─────────────────────────────────────────────
// approvalOK: is `approval` a live approval for exactly `purpose` on this box?
// A device-signed one is verified here; a box-issued id is checked against the
// pairing's approvals file (stepup.go). Neither is burned by this call —
// markApprovalUsed does that once the act it covered has run.
func approvalOK(dir, approval, purpose string) error {
if strings.TrimSpace(approval) == "" {
return errors.New("this machine requires your approval for this: none was given " +
"(approve it on your device, or here with `privatae-bastion approvals`)")
}
if isDeviceApproval(approval) {
_, err := verifyDeviceApproval(approval, purpose, time.Now())
return err
}
return stepupCheckApproval(dir, approval, purpose)
}
// approvalRequired: does a change on this box need the person's approval?
// Always on an anchored box (writes.go); on a local box only when the person
// enrolled a factor here — the rule the DCN lanes have had since stepup.go.
func approvalRequired(dir string) bool {
return currentAnchor() != nil || stepupEnrolled(dir)
}
// boxApproval is the wall a change runs on this box: there must be SOME way
// to approve here (a device pinned, or a factor enrolled for this pairing) —
// never "unenrolled passes" — and then the approval must check out for this
// purpose. The refusal names the fix.
func boxApproval(dir, approval, purpose string) error {
if !secondFactorAvailable(dir) {
return errors.New(noWayToApprove)
}
return approvalOK(dir, approval, purpose)
}
// noWayToApprove is the refusal for a box that holds neither a device key nor
// a factor. Named so the quarantine can tell it apart: it is the box's own
// state, not a host probing, and does not count as a strike (quarantine.go).
const noWayToApprove = "needs your approval, and this machine holds no way to take one: " +
"pin your approval device (`privatae-bastion device-init` on your own machine, then " +
"`privatae-bastion pin-device <key>` here with machine authority), or enrol a factor " +
"here (`privatae-bastion stepup-enroll`)"
// consumeApproval is approvalOK and the burn as one step: the approval is
// spent the moment it is accepted, before the act runs. This is what the
// write gate and the session lane call — a slow handler must not leave a
// window in which the same approval passes a second worker. A handler that
// then refuses on its own argument check has spent the approval; the host
// asks again, which is the safe direction. Returns the approval's identity
// (a device nonce or a console id) for the audit line.
func consumeApproval(dir, approval, purpose string) (string, error) {
if strings.TrimSpace(approval) == "" {
return "", errors.New("this machine requires your approval for this: none was given " +
"(approve it on your device, or here with `privatae-bastion approvals`)")
}
if isDeviceApproval(approval) {
a, err := consumeDeviceApproval(approval, purpose, time.Now())
if err != nil {
return "", err
}
return "device:" + a.Nonce, nil
}
if err := stepupConsumeApproval(dir, approval, purpose); err != nil {
return "", err
}
return "console:" + strings.TrimSpace(approval), nil
}
// boxConsume is boxApproval with the burn (consumeApproval): the wall, then
// the spend, one call.
func boxConsume(dir, approval, purpose string) (string, error) {
if !secondFactorAvailable(dir) {
return "", errors.New(noWayToApprove)
}
return consumeApproval(dir, approval, purpose)
}
// markApprovalUsed burns whichever kind it was.
func markApprovalUsed(dir, approval string) {
if isDeviceApproval(approval) {
var a deviceApproval
if json.Unmarshal([]byte(strings.TrimSpace(approval)), &a) == nil && a.Nonce != "" {
burnNonce(&a)
}
return
}
stepupMarkUsed(dir, approval)
}
// ── the device's side: the person's OWN bastion ──────────────────────────────
type deviceKeyFile struct {
Pub string `json:"pub"`
Priv string `json:"priv"`
CreatedAt string `json:"created_at"`
}
func deviceKeyPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "device_key.json"), nil
}
func deviceKeyLoad() (*deviceKeyFile, error) {
p, err := deviceKeyPath()
if err != nil {
return nil, err
}
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
var f deviceKeyFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, err
}
return &f, nil
}
// deviceInit creates the device key here, once. Prints the public key the
// person pins on each machine they govern (`pin-device <key>` there).
func deviceInit() (string, error) {
if f, err := deviceKeyLoad(); err == nil && f.Pub != "" {
return f.Pub, nil
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", err
}
f := deviceKeyFile{
Pub: base64.StdEncoding.EncodeToString(pub),
Priv: base64.StdEncoding.EncodeToString(priv),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
raw, _ := json.MarshalIndent(&f, "", " ")
p, err := deviceKeyPath()
if err != nil {
return "", err
}
if err := atomicWrite(p, raw); err != nil {
return "", err
}
_ = os.Chmod(p, 0o600)
return f.Pub, nil
}
// deviceSign issues a one-run approval for `purpose`, valid for ttl (clamped
// to deviceApprovalMaxTTL), from the key held here.
func deviceSign(purpose string, ttl time.Duration) (string, error) {
f, err := deviceKeyLoad()
if err != nil || f.Priv == "" {
return "", errors.New("this machine holds no device key — run `privatae-bastion device-init` first")
}
priv, err := base64.StdEncoding.DecodeString(f.Priv)
if err != nil || len(priv) != ed25519.PrivateKeySize {
return "", errors.New("bad device key file")
}
if strings.ContainsAny(purpose, "\r\n") {
return "", errors.New("a purpose with a line break cannot be signed: the box would refuse it")
}
if ttl <= 0 || ttl > deviceApprovalMaxTTL {
ttl = deviceApprovalMaxTTL
}
n := make([]byte, 16)
if _, err := rand.Read(n); err != nil {
return "", err
}
a := deviceApproval{
V: deviceApprovalVersion, Purpose: purpose, Nonce: hex.EncodeToString(n),
Expires: time.Now().Add(ttl).UTC().Format(time.RFC3339), Uses: 1, Key: f.Pub,
}
a.Sig = base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.PrivateKey(priv), a.canonical()))
raw, _ := json.Marshal(&a)
return string(raw), nil
}
// A pending ask on the device: the box that wants the approval, the purpose
// it derived, and the words the person reads (from the box, relayed by the
// pod — the purpose is what binds, the words are for the person).
type deviceRequest struct {
Purpose string `json:"purpose"`
Words string `json:"words"`
Server string `json:"server"`
Details map[string]any `json:"details,omitempty"`
At string `json:"at"`
State string `json:"state"` // pending | approved | denied
Approval string `json:"approval,omitempty"`
// Delivered: the signed approval was handed to the pod once. A later ask
// for the same purpose is a NEW act and files a new request — a spent or
// expired approval is never handed back to be refused by the box.
Delivered bool `json:"delivered,omitempty"`
// A ship plan ask (device_plan.go), and whether it needs the code.
Plan bool `json:"plan,omitempty"`
NeedsCode bool `json:"needs_code,omitempty"`
}
// deviceApprovalLive: the approval's own expiry has not passed.
func deviceApprovalLive(approval string, now time.Time) bool {
var a deviceApproval
if json.Unmarshal([]byte(strings.TrimSpace(approval)), &a) != nil {
return false
}
exp, err := time.Parse(time.RFC3339, a.Expires)
return err == nil && exp.After(now)
}
func deviceRequestsPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "device_requests.json"), nil
}
func deviceRequestsLoad() map[string]deviceRequest {
out := map[string]deviceRequest{}
p, err := deviceRequestsPath()
if err != nil {
return out
}
raw, err := os.ReadFile(p)
if err == nil {
_ = json.Unmarshal(raw, &out)
}
for id, r := range out {
if t, err := time.Parse(time.RFC3339, r.At); err != nil || time.Since(t) > deviceRequestTTL {
delete(out, id)
}
}
return out
}
func deviceRequestsSave(m map[string]deviceRequest) error {
p, err := deviceRequestsPath()
if err != nil {
return err
}
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return atomicWrite(p, raw)
}
// deviceNewRequest files an ask from its DETAILS — the same plain fields the
// box's own console flow takes (stepup.go purposeFromDetails). The device
// derives the purpose it will sign and the words it will show from those one
// fields, so a pod cannot show one sentence and have another signed: the
// purpose IS the words (stepup.go's doctrine, kept here). A repeat of the same
// pending purpose from the same box returns the existing id; one the person
// denied is not asked again for the request TTL.
//
// A root shell is never signed here: root is approved at the machine with
// the code (session.go), on top of any tap.
func deviceNewRequest(details map[string]any) (string, error) {
kind, _ := details["kind"].(string)
// ONLY A KIND WHOSE PURPOSE NAMES THE MACHINE is signed here. mgmt_op and
// dcn_store_grant carry the server inside the purpose, so an approval for
// one box is no use on another. A move plan's and a model pull's purposes
// do not (purposeForPlan, purposeForModelPull) — an approval for either
// would run once on whichever of the person's machines the pod chose — so
// those stay on that machine's console until their purposes carry it. Root
// is never signed on a device.
switch kind {
// service_reach_grant names the machine too (purposeForReach: server|slot|
// subject|ttl), so an approval for one box is no use on another; so do
// op_run and op_grant (ops.go).
case "mgmt_op", "dcn_store_grant", reachKind, opKindRun, opKindGrant:
case "session_root":
return "", errors.New("a root shell is approved at that machine with your code, not from a device")
default:
return "", fmt.Errorf("a %q ask is approved at the machine's own console, not from a device", kind)
}
server, _ := details["server"].(string)
server = strings.TrimSpace(server)
if server == "" || strings.ContainsAny(server, " \n\r\t|") {
return "", errors.New("the ask names no machine")
}
purpose, err := purposeFromDetails(details)
if err != nil {
return "", err
}
words := describeRequest(stepupRequest{Details: details})
deviceMu.Lock()
defer deviceMu.Unlock()
m := deviceRequestsLoad()
// 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.Server == server && r.State == "denied" {
return "", errors.New("you refused this on your device; it cannot be asked again for 24h")
}
}
for id, r := range m {
if r.Purpose == purpose && r.Server == server {
switch r.State {
case "pending":
return id, nil
case "approved":
// An approved one is handed back ONCE and while it is live, so
// a pod that files, waits out its turn and files again finds
// the tap it missed instead of raising a second card — and a
// pod asking again after that (a new act) gets a new card, not
// a spent or expired approval the box would refuse.
if !r.Delivered && deviceApprovalLive(r.Approval, time.Now()) {
return id, nil
}
}
}
}
id, err := newID()
if err != nil {
return "", err
}
m[id] = deviceRequest{Purpose: purpose, Words: words, Server: server, Details: details,
At: time.Now().UTC().Format(time.RFC3339), State: "pending"}
// (Server is the details' own server — the same one the purpose names.)
if err := deviceRequestsSave(m); err != nil {
return "", err
}
return id, nil
}
func deviceApprove(id string) (string, error) { return deviceApproveWithCode(id, "") }
// deviceApproveWithCode signs an ask; a ship plan with root work needs the
// person's code, checked here (device_plan.go).
func deviceApproveWithCode(id, code string) (string, error) {
deviceMu.Lock()
defer deviceMu.Unlock()
m := deviceRequestsLoad()
r, ok := m[id]
if !ok || r.State != "pending" {
return "", fmt.Errorf("no pending request %s", id)
}
var approval string
var err error
if r.Plan {
approval, err = devicePlanSign(r, code)
} else {
approval, err = deviceSign(r.Purpose, deviceApprovalMaxTTL)
}
if err != nil {
return "", err
}
r.State, r.Approval = "approved", approval
m[id] = r
if err := deviceRequestsSave(m); err != nil {
return "", err
}
return approval, nil
}
func deviceDeny(id string) error {
deviceMu.Lock()
defer deviceMu.Unlock()
m := deviceRequestsLoad()
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 = "denied"
m[id] = r
return deviceRequestsSave(m)
}
func sortedDeviceRequests(m map[string]deviceRequest) []string {
ids := make([]string, 0, len(m))
for id := range m {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
return ids
}
// ── the device's mgmt ops (the pod → the person's own bastion) ───────────────
// deviceLaneOnly: the device's ops are answered only for the PERSON'S OWN
// host — the anchor pairing on an anchored device, the one pairing on a local
// one (anchorOnly). A member host paired to the device could otherwise file
// asks on it or read back a signed approval by its id.
func deviceLaneOnly(ws *websocket.Conn, what string) bool {
self := ""
if a := mgmtAccountOf(ws); a != nil {
self = a.self
}
if why := anchorOnly(self, what); why != "" {
refuseMgmt(ws, why)
return false
}
return true
}
// device_request <details_b64>: file an ask on this device from the plain
// details of what a machine is being asked for — the machine is the one the
// details name, and nothing else. Answers {id, state}. The pod cannot
// approve — only the person, here.
func handleDeviceRequest(ws *websocket.Conn, _ string, args []string) {
if !deviceLaneOnly(ws, "device_request") {
return
}
if len(args) < 1 {
sendMgmt(ws, 'R', []byte("device_request: need <details_b64>\n"))
sendMgmtExit(ws, 1)
return
}
if _, err := deviceKeyLoad(); err != nil {
sendMgmt(ws, 'R', []byte("this machine is not an approval device (privatae-bastion device-init)\n"))
sendMgmtExit(ws, 126)
return
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[0]))
if err != nil {
sendMgmt(ws, 'R', []byte("device_request: bad details_b64\n"))
sendMgmtExit(ws, 1)
return
}
var details map[string]any
if err := json.Unmarshal(raw, &details); err != nil || details == nil {
sendMgmt(ws, 'R', []byte("device_request: bad details json\n"))
sendMgmtExit(ws, 1)
return
}
var id string
if kind, _ := details["kind"].(string); kind == shipPlanKind {
id, err = deviceNewPlanRequest(details)
} else {
id, err = deviceNewRequest(details)
}
if err != nil {
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
sendMgmtExit(ws, 126)
return
}
v := map[string]any{"id": id, "state": "pending"}
if approval, ok := deviceHandOut(id); ok {
v["state"], v["approval"] = "approved", approval
} else {
notifyDeviceRequest(id)
}
out, _ := json.Marshal(v)
sendMgmt(ws, 'O', out)
sendMgmtExit(ws, 0)
}
// deviceHandOut gives the approval for an approved request to the pod ONCE
// (marks it delivered). ("", false) for anything else.
func deviceHandOut(id string) (string, bool) {
deviceMu.Lock()
defer deviceMu.Unlock()
m := deviceRequestsLoad()
r, ok := m[id]
if !ok || r.State != "approved" || r.Delivered ||
!(deviceApprovalLive(r.Approval, time.Now()) || devicePlanLive(r.Approval, time.Now())) {
return "", false
}
r.Delivered = true
m[id] = r
if err := deviceRequestsSave(m); err != nil {
return "", false
}
return r.Approval, true
}
// device_status <id>: {state, approval?} — the signed approval only once the
// person tapped.
func handleDeviceStatus(ws *websocket.Conn, _ string, args []string) {
if !deviceLaneOnly(ws, "device_status") {
return
}
if len(args) < 1 {
sendMgmt(ws, 'R', []byte("device_status: need <id>\n"))
sendMgmtExit(ws, 1)
return
}
id := strings.TrimSpace(args[0])
deviceMu.Lock()
r, ok := deviceRequestsLoad()[id]
deviceMu.Unlock()
if !ok {
out, _ := json.Marshal(map[string]any{"state": "expired"})
sendMgmt(ws, 'O', out)
sendMgmtExit(ws, 0)
return
}
v := map[string]any{"state": r.State}
if r.State == "approved" {
// Handed out once; a second status read of an approved request says
// "delivered" — the approval left already and is not repeated.
if approval, ok := deviceHandOut(id); ok {
v["approval"] = approval
} else {
v["state"] = "delivered"
}
}
out, _ := json.Marshal(v)
sendMgmt(ws, 'O', out)
sendMgmtExit(ws, 0)
}
// notifyDeviceRequest says on this machine's console that a box is asking.
// On Windows the tray also raises a notification for it and opens the
// approvals popup on a click (uidevice.go); the console stays the answer
// everywhere else.
func notifyDeviceRequest(id string) {
fmt.Printf("device: approval requested (%s) — privatae-bastion device-approvals\n", id)
}
// ── the console ──────────────────────────────────────────────────────────────
func cmdDeviceInit() error {
pub, err := deviceInit()
if err != nil {
return err
}
fmt.Println("This machine is an approval device. Pin its key on each machine you govern,")
fmt.Println("there, with machine authority:")
fmt.Println()
fmt.Println(" privatae-bastion pin-device " + pub)
fmt.Println()
fmt.Println("Every write a host asks of those machines then needs your tap here.")
return nil
}
// deviceShownPath records the ids `device-approvals` last PRINTED, so that
// `device-approve all` signs exactly the asks the person read and nothing a
// pod filed after they looked.
func deviceShownPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "device_shown.json"), nil
}
// deviceAsk is one ask waiting on this approval device.
type deviceAsk struct {
ID string `json:"id"`
Server string `json:"server"`
At string `json:"at"`
Words string `json:"words"`
}
type deviceApprovalsRead struct {
Asks []deviceAsk `json:"asks"`
}
func readDeviceApprovals(readOpts) (deviceApprovalsRead, error) {
deviceMu.Lock()
m := deviceRequestsLoad()
deviceMu.Unlock()
v := deviceApprovalsRead{Asks: []deviceAsk{}}
for _, id := range sortedDeviceRequests(m) {
r := m[id]
if r.State != "pending" {
continue
}
v.Asks = append(v.Asks, deviceAsk{ID: id, Server: r.Server, At: r.At, Words: r.Words})
}
return v, nil
}
// cmdDeviceApprovals lists the asks waiting here. Read in words by this
// process, it records them as shown, which is all `device-approve all` will
// sign. --json is for a program, not a person, and records nothing; nor does
// a list read through the lane, which `device-approve` from this account
// would not find.
func cmdDeviceApprovals(args []string) error {
asJSON, err := jsonOnly("usage: privatae-bastion device-approvals [--json]", args)
if err != nil {
return err
}
lane := readsGoThroughLane()
v, err := readMachine("device-approvals", readOpts{}, readDeviceApprovals)
if err != nil {
return laneRefusalError("device-approvals", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printDeviceApprovals(os.Stdout, v, lane)
if !lane {
shown := []string{}
for _, a := range v.Asks {
shown = append(shown, a.ID)
}
if p, err := deviceShownPath(); err == nil {
raw, _ := json.Marshal(shown)
_ = atomicWrite(p, raw)
}
}
return nil
}
// printDeviceApprovals prints the asks; lane: they were read from the
// machine's bastion, so they are answered as its administrator.
func printDeviceApprovals(w io.Writer, v deviceApprovalsRead, lane bool) {
for _, a := range v.Asks {
fmt.Fprintf(w, "[%s] on %s, asked %s\n%s\n\n", a.ID[:min(8, len(a.ID))], a.Server, a.At, a.Words)
}
switch {
case len(v.Asks) == 0:
fmt.Fprintln(w, "nothing is waiting for your approval")
case lane:
fmt.Fprintln(w, "These wait on the machine's bastion. As the administrator: sudo -H privatae-bastion device-approvals, "+
"then device-approve <id> or device-approve all")
default:
fmt.Fprintln(w, "approve one: privatae-bastion device-approve <id> all of the above: privatae-bastion device-approve all")
}
}
func cmdDeviceApprove(prefix string) error { return cmdDeviceApproveCode(prefix, "") }
func cmdDeviceApproveCode(prefix, code string) error {
deviceMu.Lock()
m := deviceRequestsLoad()
deviceMu.Unlock()
// `all`: every ask the person was SHOWN by the last `device-approvals`
// and nothing else — a fleet roll files one ask per machine, and
// twenty-three taps for one decision was refused as a design for the
// update button (Network app 1.9.0), but a pod can file an ask at any
// moment, and "all" must not sign one that arrived after the person read
// the list. An ask filed since is left pending and named.
if strings.TrimSpace(prefix) == "all" {
var shown []string
if p, err := deviceShownPath(); err == nil {
if raw, err := os.ReadFile(p); err == nil {
_ = json.Unmarshal(raw, &shown)
}
}
if len(shown) == 0 {
return errors.New("nothing was shown: run `privatae-bastion device-approvals` first, then approve what it printed")
}
wasShown := map[string]bool{}
for _, id := range shown {
wasShown[id] = true
}
n, later, coded := 0, 0, 0
for _, id := range sortedDeviceRequests(m) {
if m[id].State != "pending" {
continue
}
if !wasShown[id] {
later++
continue
}
if m[id].NeedsCode {
// Root work is approved one at a time, with the code.
coded++
continue
}
if _, err := deviceApprove(id); err != nil {
return err
}
fmt.Printf("approved %s — on %s\n", id[:8], m[id].Server)
n++
}
if p, err := deviceShownPath(); err == nil {
_ = os.Remove(p)
}
if n == 0 {
fmt.Println("nothing you were shown is still waiting")
}
if later > 0 {
fmt.Printf("%d ask(s) arrived after you looked and were NOT approved — run device-approvals to read them\n", later)
}
if coded > 0 {
fmt.Printf("%d ship plan(s) run root work and need your code: privatae-bastion device-approve <id> --code <code>\n", coded)
}
return nil
}
id, err := findDeviceRequest(m, prefix)
if err != nil {
return err
}
if _, err := deviceApproveWithCode(id, code); err != nil {
return err
}
if m[id].Plan {
fmt.Printf("approved %s — each machine in it runs its own lines, once\n", id[:8])
return nil
}
fmt.Printf("approved %s — it may run once on %s\n", id[:8], m[id].Server)
return nil
}
// cmdDeviceDeny refuses one ask waiting on this approval device (through the
// owner's lane beside the machine's bastion: denying takes nothing).
func cmdDeviceDeny(prefix string) error {
return printOrAct(agentAskRequest{Act: "device-deny", ID: prefix}, "device-deny "+prefix,
func() (string, error) { return deviceDenyText(prefix) })
}
func deviceDenyText(prefix string) (string, error) {
deviceMu.Lock()
m := deviceRequestsLoad()
deviceMu.Unlock()
id, err := findDeviceRequest(m, prefix)
if err != nil {
return "", err
}
if err := deviceDeny(id); err != nil {
return "", err
}
return "denied " + id, nil
}
func findDeviceRequest(m map[string]deviceRequest, prefix string) (string, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return "", errors.New("which request? (privatae-bastion device-approvals lists them)")
}
var hits []string
for id := range m {
if strings.HasPrefix(id, prefix) {
hits = append(hits, id)
}
}
switch len(hits) {
case 0:
return "", fmt.Errorf("no request starts with %q", prefix)
case 1:
return hits[0], nil
}
return "", fmt.Errorf("%d requests start with %q — give more of the id", len(hits), prefix)
}
func cmdPinDevice(key string, unpin bool) error {
act := "pin-device"
if unpin {
act = "unpin-device"
}
if err := requireMachineAuthority(act); err != nil {
return err
}
if unpin {
if err := unpinDeviceKey(key); err != nil {
return err
}
fmt.Println("device key unpinned; approvals signed by it are refused from now on")
// Every op grant it approved dies with it (ops_bounds.go), dropped
// now so pinning the key again does not bring one back.
if n := opsDropApprovedBy("device:" + strings.TrimSpace(key)); n > 0 {
fmt.Printf("%d op grant(s) it approved dropped\n", n)
}
return nil
}
if err := pinDeviceKey(key); err != nil {
return err
}
fmt.Println("device key pinned; a write a host asks of this machine may now be approved from that device")
if line := machineKeyLine(); line != "" {
fmt.Println()
fmt.Println("So that device can sign ship plans that include this machine, run this on the device:")
fmt.Println()
fmt.Println(" " + line)
}
return nil
}