344 lines
11 KiB
Go
344 lines
11 KiB
Go
package main
|
|
|
|
// device_plan.go — the approval device signing a ship plan (ops_plan.go).
|
|
//
|
|
// The pod files a DRAFT: the pipeline, the commit, the expiry, and the lines.
|
|
// The device makes the plan it will sign from it, filling in what only it may
|
|
// say:
|
|
//
|
|
// - each machine's key, from its OWN pins (pin-machine, typed by the person
|
|
// from what the machine printed when this device was pinned there) —
|
|
// a machine the device has not pinned makes the whole plan unsignable;
|
|
// - a fresh nonce, and a version one above the last it signed for that
|
|
// pipeline, so a machine can refuse an older plan;
|
|
// - whether the plan holds root work (any line with verbs), and, when it
|
|
// does, that the person's code was checked HERE before signing.
|
|
//
|
|
// What the person reads is rendered from the plan itself, line by line: the
|
|
// machine, the op, its params, the verbs with their arguments, the inputs it
|
|
// takes from earlier steps, and whether a line may be undone or adopts the
|
|
// live image. The words and the signed bytes come from the same plan.
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
const planDeviceVersionsFile = "device_plan_versions.json"
|
|
|
|
// devicePlanDraft is what the pod sends in a ship_plan ask's details.
|
|
type devicePlanDraft struct {
|
|
Pipeline string `json:"pipeline"`
|
|
Commit string `json:"commit"`
|
|
Expires string `json:"expires"`
|
|
Lines []shipPlanLine `json:"lines"`
|
|
}
|
|
|
|
// devicePlanFromDetails builds the plan this device would sign (without its
|
|
// version, nonce or code flag, which are set at signing) and checks it.
|
|
func devicePlanFromDetails(details map[string]any) (*shipPlan, error) {
|
|
raw, err := json.Marshal(details["plan"])
|
|
if err != nil {
|
|
return nil, errors.New("the ask carries no plan")
|
|
}
|
|
var d devicePlanDraft
|
|
if err := json.Unmarshal(raw, &d); err != nil {
|
|
return nil, errors.New("the plan in the ask does not parse")
|
|
}
|
|
f, err := deviceKeyLoad()
|
|
if err != nil || f.Pub == "" {
|
|
return nil, errors.New("this machine is not an approval device (privatae-bastion device-init)")
|
|
}
|
|
pins := machinePinsLoad()
|
|
p := &shipPlan{V: shipPlanVersion, Kind: shipPlanKind, Pipeline: d.Pipeline, Version: 1, Commit: d.Commit,
|
|
Nonce: strings.Repeat("0", planNonceMin), Expires: d.Expires, Device: f.Pub, Machines: map[string]string{},
|
|
Lines: d.Lines}
|
|
for _, l := range d.Lines {
|
|
key, ok := pins[l.Machine]
|
|
if !ok {
|
|
return nil, fmt.Errorf("this device has not pinned %s's key: pin it here first (privatae-bastion pin-machine %s <key>, with the key %s printed when you pinned this device there)", l.Machine, l.Machine, l.Machine)
|
|
}
|
|
p.Machines[l.Machine] = key
|
|
if len(l.Verbs) > 0 {
|
|
p.Root = true
|
|
}
|
|
}
|
|
if err := planShape(p); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// devicePlanWords renders the plan for the person: what they approve, line by
|
|
// line, from the plan's own fields.
|
|
func devicePlanWords(p *shipPlan) string {
|
|
var b strings.Builder
|
|
exp, _ := time.Parse(time.RFC3339, p.Expires)
|
|
fmt.Fprintf(&b, "Ship %s at commit %s: %d step(s), within %s.\n", wordsSafe(p.Pipeline), wordsSafe(truncate(p.Commit, 12)), len(p.Lines),
|
|
time.Until(exp).Round(time.Minute))
|
|
if p.Root {
|
|
b.WriteString("It runs ROOT work on your machines (the steps marked root below): approving it needs your code.\n")
|
|
}
|
|
for _, l := range p.Lines {
|
|
kind := "program only"
|
|
if len(l.Verbs) > 0 {
|
|
kind = "root"
|
|
}
|
|
fmt.Fprintf(&b, "%d. %s on %s: op %s (%s, %s) with %s\n", l.N, wordsSafe(l.Step), wordsSafe(l.Machine),
|
|
wordsSafe(l.Op), wordsSafe(truncate(l.OpSHA256, 16)), kind, wordsSafe(l.Params))
|
|
if l.Attempts > 1 {
|
|
fmt.Fprintf(&b, " may run up to %d times if it fails\n", l.Attempts)
|
|
}
|
|
names := make([]string, 0, len(l.Inputs))
|
|
for n := range l.Inputs {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
m := planInputRe.FindStringSubmatch(l.Inputs[n])
|
|
if m == nil {
|
|
continue
|
|
}
|
|
if m[2] != "" {
|
|
fmt.Fprintf(&b, " %s = the digest of %s that step %s builds\n", wordsSafe(n), wordsSafe(m[2]), wordsSafe(m[1]))
|
|
} else {
|
|
fmt.Fprintf(&b, " %s = the digest step %s builds\n", wordsSafe(n), wordsSafe(m[1]))
|
|
}
|
|
}
|
|
for _, v := range l.Verbs {
|
|
keys := make([]string, 0, len(v.Args))
|
|
for k := range v.Args {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var args []string
|
|
for _, k := range keys {
|
|
args = append(args, wordsSafe(k)+"="+wordsSafe(v.Args[k]))
|
|
}
|
|
fmt.Fprintf(&b, " %s %s\n", wordsSafe(v.Verb), strings.Join(args, " "))
|
|
}
|
|
if l.Undo {
|
|
b.WriteString(" may be undone, to what that machine had before, if a later step fails\n")
|
|
}
|
|
if l.AdoptLive {
|
|
b.WriteString(" FIRST ROLL: takes the image running now as its starting point, without checking its history\n")
|
|
}
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
// wordsSafe is a field as the person may read it: no control character, no
|
|
// line break, no Unicode format or direction character, which could redraw,
|
|
// hide or reorder what they read. The plan's form refuses them already
|
|
// (planShape); this is what the words do whatever reached them.
|
|
func wordsSafe(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if r < 0x20 || (r >= 0x7f && r <= 0x9f) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
|
b.WriteRune('?')
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// planPurpose names a plan ask for de-duplication: the same draft from the
|
|
// same pod is the same ask.
|
|
func planPurpose(p *shipPlan) string {
|
|
raw, _ := json.Marshal(p)
|
|
sum := sha256.Sum256(raw)
|
|
return shipPlanKind + "|" + p.Pipeline + "|" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// devicePlanSign signs the plan of an approved ask: a fresh nonce, the next
|
|
// version for the pipeline, and — for a root plan — the code, checked here
|
|
// against the factor enrolled on the pairing the ask came in on.
|
|
func devicePlanSign(r deviceRequest, code string) (string, error) {
|
|
p, err := devicePlanFromDetails(r.Details)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if p.Root {
|
|
if strings.TrimSpace(code) == "" {
|
|
return "", errors.New("this plan runs root work: approve it with your code")
|
|
}
|
|
dir, err := deviceFactorDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := stepupVerifyAndBurn(dir, code, time.Now()); err != nil {
|
|
return "", fmt.Errorf("your code: %v", err)
|
|
}
|
|
p.CodeGiven = true
|
|
}
|
|
f, err := deviceKeyLoad()
|
|
if err != nil || f.Priv == "" {
|
|
return "", errors.New("this machine holds no device key")
|
|
}
|
|
priv, err := base64.StdEncoding.DecodeString(f.Priv)
|
|
if err != nil || len(priv) != ed25519.PrivateKeySize {
|
|
return "", errors.New("bad device key file")
|
|
}
|
|
n := make([]byte, 16)
|
|
if _, err := rand.Read(n); err != nil {
|
|
return "", err
|
|
}
|
|
p.Nonce = hex.EncodeToString(n)
|
|
v, err := deviceNextPlanVersion(p.Pipeline)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p.Version = v
|
|
if err := planShape(p); err != nil {
|
|
return "", err
|
|
}
|
|
body, _ := json.Marshal(p)
|
|
env := shipPlanEnvelope{PayloadType: shipPlanPayloadType, Payload: base64.StdEncoding.EncodeToString(body),
|
|
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.PrivateKey(priv), dssePAE(shipPlanPayloadType, body)))}
|
|
out, _ := json.Marshal(env)
|
|
return string(out), nil
|
|
}
|
|
|
|
// deviceFactorDir is where the person's own factor for this device is
|
|
// enrolled: the anchor pairing's (the device lane answers only its anchor,
|
|
// deviceLaneOnly), or the only pairing of an unanchored device. Never a
|
|
// folder the ask names: the pod sends the ask.
|
|
func deviceFactorDir() (string, error) {
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return "", errors.New("this device's pairings cannot be read, so there is no factor to check your code against")
|
|
}
|
|
var pick *Config
|
|
if a := currentAnchor(); a != nil {
|
|
for _, c := range cfgs {
|
|
if c != nil && c.HostName == a.AnchorHost {
|
|
pick = c
|
|
}
|
|
}
|
|
} else if len(cfgs) == 1 {
|
|
pick = cfgs[0]
|
|
}
|
|
if pick == nil {
|
|
return "", errors.New("this device has no pairing its factor belongs to (anchor it, or keep one pairing)")
|
|
}
|
|
dir := pick.dcnDir()
|
|
if dir == "" {
|
|
return "", errors.New("this device's pairing has no folder for its factor")
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
// deviceNextPlanVersion is one above the last version this device signed for
|
|
// the pipeline, recorded before it is used.
|
|
func deviceNextPlanVersion(pipeline string) (int64, error) {
|
|
m := planLedgerLoad(planDeviceVersionsFile)
|
|
var v int64
|
|
_, _ = fmt.Sscan(m[pipeline], &v)
|
|
v++
|
|
m[pipeline] = fmt.Sprint(v)
|
|
if err := planLedgerSave(planDeviceVersionsFile, m); err != nil {
|
|
return 0, fmt.Errorf("could not record the plan's version: %v", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// devicePlanLive: a signed plan envelope whose plan has not expired.
|
|
func devicePlanLive(approval string, now time.Time) bool {
|
|
var env shipPlanEnvelope
|
|
if json.Unmarshal([]byte(strings.TrimSpace(approval)), &env) != nil || env.PayloadType != shipPlanPayloadType {
|
|
return false
|
|
}
|
|
body, err := base64.StdEncoding.DecodeString(env.Payload)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var p shipPlan
|
|
if json.Unmarshal(body, &p) != nil {
|
|
return false
|
|
}
|
|
exp, err := time.Parse(time.RFC3339, p.Expires)
|
|
return err == nil && exp.After(now)
|
|
}
|
|
|
|
// deviceNewPlanRequest files a ship_plan ask. A root plan's code is checked,
|
|
// when the person approves, against the factor enrolled on this device's own
|
|
// pairing (deviceFactorDir), never one the ask names.
|
|
func deviceNewPlanRequest(details map[string]any) (string, error) {
|
|
p, err := devicePlanFromDetails(details)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
purpose := planPurpose(p)
|
|
machines := make([]string, 0, len(p.Machines))
|
|
for h := range p.Machines {
|
|
machines = append(machines, h)
|
|
}
|
|
sort.Strings(machines)
|
|
server := strings.Join(machines, ", ")
|
|
deviceMu.Lock()
|
|
defer deviceMu.Unlock()
|
|
m := deviceRequestsLoad()
|
|
for _, r := range m {
|
|
if r.Purpose == purpose && 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.State == "pending" || (r.State == "approved" && !r.Delivered && devicePlanLive(r.Approval, time.Now()))) {
|
|
return id, nil
|
|
}
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
m[id] = deviceRequest{Purpose: purpose, Words: devicePlanWords(p), Server: server, Details: details,
|
|
At: time.Now().UTC().Format(time.RFC3339), State: "pending", Plan: true, NeedsCode: p.Root}
|
|
if err := deviceRequestsSave(m); err != nil {
|
|
return "", err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// cmdPinMachine / cmdUnpinMachine: the person, at their device.
|
|
func cmdPinMachine(host, key string, unpin bool) error {
|
|
if _, err := deviceKeyLoad(); err != nil {
|
|
return errors.New("this machine is not an approval device (privatae-bastion device-init)")
|
|
}
|
|
if unpin {
|
|
if err := unpinMachineKey(host); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s unpinned; this device signs no plan for it until it is pinned again\n", host)
|
|
return nil
|
|
}
|
|
if err := pinMachineKey(host, key); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s pinned; this device may now sign ship plans that include it\n", host)
|
|
return nil
|
|
}
|
|
|
|
// machineKeyLine is what a machine prints when this device is pinned there:
|
|
// the command the person runs on the device to pin the machine back.
|
|
func machineKeyLine() string {
|
|
pub, _, err := loadOrCreateOwnerKey()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
hn, _ := os.Hostname()
|
|
return "privatae-bastion pin-machine " + hn + " " + base64.StdEncoding.EncodeToString(pub)
|
|
}
|