diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go new file mode 100644 index 00000000..feb3cbc0 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -0,0 +1,472 @@ +package main + +// What the vault takes in, and how it rotates (novox/hq ADR 0349 clauses 8 to 10; ADR 0277 §22–§27 and ADR 0305 +// with the vault in the controller's place; design 57's V2, issue 675). +// +// Four verbs of the seat: `accept` (a value given at the controller's terminal), `secret-ask` (a value the +// operator types at a desk), `issued` (a value a provider's software issued) and `rotate`. +// +// **A value comes in sealed to a key the vault made for that one call, and opens only here.** The first three +// are two steps of one verb. The first names what is taken and answers a call and the public half of a key made +// for it (`seal_to`, a raw X25519 key in base64, the NaCl anonymous box the desk's prompt and a provider's tool +// already seal to); the second hands back what was sealed to that key, and the vault opens it with the private +// half, which never leaves this process and is forgotten once used or after two minutes. +// +// **The controller carries the call, and cannot open what it carries.** The desk's prompt and a provider's tool +// answer the controller alone: the bus lets only the controller ask them, on the subject that names its caller +// (issue 365). The vault cannot call in a name of its own while it runs in its machine's tool runner, whose one +// bus account carries that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them on +// the vault's behalf with the vault's key, and passes on the sealed answer: the value is opened in the vault and +// nowhere else (ADR 0349 clause 9, ADR 0277 §23), and the vault takes these steps from the controller alone. +// +// Which secret each one is, by name in the vault's store: +// +// own/// a module's own secret on one machine +// provision// the one credential of a provider assignment and provision (clause 8) + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "golang.org/x/crypto/nacl/box" +) + +// ControllerCaller is the controller's bus user: the one caller that carries a value in (broker.ControllerName). +const ControllerCaller = "controller" + +// Where a value came from. +const ( + OriginMade = "made" + OriginAccepted = "accepted" + OriginTyped = "typed" + OriginIssued = "issued" +) + +// CallOpenFor is how long a call's key waits for its sealed value: longer than any prompt or tool waits. +const CallOpenFor = 2 * time.Minute + +// The bounds of asking for one secret (ADR 0277 §24, the controller's inventory.SecretAsk* until now). +const ( + // SecretAskOpenFor is how long an ask that has not ended counts as open. + SecretAskOpenFor = 2 * time.Minute + // SecretAsksPerHour is how many asks for one secret an hour takes. + SecretAsksPerHour = 3 +) + +var namePart = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) + +// OwnSecret is the vault's name for a module's own secret on one machine. +func OwnSecret(node, module, name string) (string, error) { + for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name} { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return "own/" + node + "/" + module + "/" + name, nil +} + +// ProvisionSecret is the vault's name for the one credential of a provider assignment and provision. +func ProvisionSecret(provider, provision string) (string, error) { + for what, v := range map[string]string{"the provider's machine": provider, "the provision": provision} { + if !namePart.MatchString(v) { + return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v) + } + } + return "provision/" + provider + "/" + provision, nil +} + +// callKey is one call's key and what the call takes in. Never written anywhere. +type callKey struct { + verb, secret, by string + // ask is the ask's record for `secret-ask`, which the call's id is. + ask *AskRecord + public [32]byte + private [32]byte + until time.Time +} + +// Opened is a first step's answer: the call, and the key to seal its value to. Never a value. +type Opened struct { + Call string `json:"call"` + SealTo string `json:"seal_to"` + Secret string `json:"secret"` + AskedBy string `json:"asked_by,omitempty"` +} + +// fromTheController refuses every caller but the controller: only it reaches a desk's prompt and a provider's +// tool, and only it carries a value given at its terminal. +func fromTheController(caller, verb string) error { + if caller != ControllerCaller { + if caller == "" { + caller = stdio.Unproven + } + return fmt.Errorf("%s takes a value from the controller alone, which carries it sealed to this vault's key "+ + "for one call; %s is not the controller", verb, caller) + } + return nil +} + +// openCall makes a key for one call and keeps its private half here until the call is taken or two minutes +// have passed. +func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, string, error) { + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + return "", "", err + } + idRaw := make([]byte, 12) + if _, err := rand.Read(idRaw); err != nil { + return "", "", err + } + id := hex.EncodeToString(idRaw) + if ask != nil { + id = ask.ID + } + v.callsMu.Lock() + defer v.callsMu.Unlock() + if v.pending == nil { + v.pending = map[string]*callKey{} + } + now := v.Now() + for k, c := range v.pending { + if now.After(c.until) { + delete(v.pending, k) + } + } + v.pending[id] = &callKey{verb: verb, secret: secret, by: by, ask: ask, public: *pub, private: *priv, + until: now.Add(CallOpenFor)} + return id, base64.StdEncoding.EncodeToString(pub[:]), nil +} + +// takeCall ends a call: its key is forgotten whatever follows. +func (v *Vault) takeCall(verb, id string) (*callKey, error) { + v.callsMu.Lock() + defer v.callsMu.Unlock() + c, ok := v.pending[id] + if !ok || c.verb != verb { + return nil, fmt.Errorf("no %s call %q is open in the vault: it ended, it was never opened, or the vault "+ + "started again; open it anew", verb, id) + } + delete(v.pending, id) + if v.Now().After(c.until) { + return nil, fmt.Errorf("the %s call %q waited longer than %s and is closed; open it anew", verb, id, CallOpenFor) + } + return c, nil +} + +// open opens what was sealed to the call's key, and forgets the key. +func (c *callKey) open(sealed string) ([]byte, error) { + defer func() { c.private = [32]byte{} }() + blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed)) + if err != nil { + return nil, errors.New("the value is not sealed to this call; nothing was taken") + } + out, ok := box.OpenAnonymous(nil, blob, &c.public, &c.private) + if !ok { + // Never the value, never what failed to open: only that it was not sealed to this call. + return nil, errors.New("the value is not sealed to this call; nothing was taken") + } + return out, nil +} + +func zero(b []byte) { + for i := range b { + b[i] = 0 + } +} + +// ---- accept ------------------------------------------------------------------------------------------------- + +// AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal +// seals to the call's key before it leaves it (ADR 0349 clause 8: the vault alone opens an accepted value). +func (v *Vault) AcceptOpen(caller, secret, by string) (Opened, error) { + if err := fromTheController(caller, "accept"); err != nil { + return Opened{}, err + } + if err := validName(secret); err != nil { + return Opened{}, err + } + if rec, held, err := v.Store.Read(secret); err != nil { + return Opened{}, err + } else if held && rec.Origin == OriginIssued { + return Opened{}, fmt.Errorf("%s is issued by its provider's software, and `secret issued` is the only way "+ + "it enters the vault (ADR 0305 §10); nothing is taken", secret) + } + id, key, err := v.openCall("accept", secret, by, nil) + if err != nil { + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// AcceptTake opens the value sealed to the call's key and keeps it, accepted: never remade by the vault. +func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) { + if err := fromTheController(caller, "accept"); err != nil { + return Changed{}, err + } + c, err := v.takeCall("accept", call) + if err != nil { + return Changed{}, err + } + value, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + defer zero(value) + if strings.TrimSpace(string(value)) == "" { + return Changed{}, errors.New("the value given is empty; nothing was taken") + } + return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, OriginAccepted) +} + +// ---- secret-ask --------------------------------------------------------------------------------------------- + +// AskOf is what an ask is for, and where. +type AskOf struct { + Node, Module, Name, Desk string + // AskedBy is who asked the controller, as the bus named its caller; the controller's word, taken from the + // controller alone. + AskedBy string +} + +// AskOpen records an ask for a module's own secret before any prompt opens, holding its bounds — one open ask +// per secret, three an hour (ADR 0277 §24, §25) — and answers the key the desk's prompt seals what is typed to, +// and who asked as the record says it, which the prompt names (§26). +func (v *Vault) AskOpen(caller string, a AskOf) (Opened, error) { + if err := fromTheController(caller, "secret-ask"); err != nil { + return Opened{}, err + } + secret, err := OwnSecret(a.Node, a.Module, a.Name) + if err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: %w", err) + } + if !namePart.MatchString(a.Desk) { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the desk %q is not a machine's name", a.Desk) + } + askedBy := askedByName(a.AskedBy) + v.Store.asksMu.Lock() + defer v.Store.asksMu.Unlock() + now := v.Now().UTC() + recent, err := v.Store.AsksSince(now.Add(-time.Hour)) + if err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the record of asks cannot be read: %w", err) + } + ended := map[string]bool{} + for _, r := range recent { + if r.Event == AskEnded { + ended[r.ID] = true + } + } + lastHour, openBy := 0, "" + for _, r := range recent { + if r.Event != AskOpened || r.Secret != secret { + continue + } + lastHour++ + if !ended[r.ID] && r.At.After(now.Add(-SecretAskOpenFor)) { + openBy = r.AskedBy + } + } + if openBy != "" { + return Opened{}, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a "+ + "secret, and this one ends within %s", a.Name, a.Module, a.Node, openBy, SecretAskOpenFor) + } + if lastHour >= SecretAsksPerHour { + return Opened{}, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks "+ + "for one secret: the operator is not kept at a prompt", a.Name, a.Module, a.Node, lastHour, SecretAsksPerHour) + } + idRaw := make([]byte, 12) + if _, err := rand.Read(idRaw); err != nil { + return Opened{}, err + } + rec := AskRecord{ID: hex.EncodeToString(idRaw), At: now, Event: AskOpened, Secret: secret, Node: a.Node, + Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedBy, Caller: caller} + if err := v.recordAsk(rec); err != nil { + return Opened{}, fmt.Errorf("nobody was asked to type anything: the ask could not be recorded: %w", err) + } + id, key, err := v.openCall("secret-ask", secret, askedBy, &rec) + if err != nil { + _ = v.recordAsk(AskRecord{ID: rec.ID, At: v.Now().UTC(), Event: AskEnded, Secret: secret, Outcome: "failed"}) + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret, AskedBy: askedBy}, nil +} + +// The outcomes an ask ends with, as the controller's record had them. +var askOutcomes = map[string]bool{"dismissed": true, "timed-out": true, "empty": true, "refused": true, "failed": true} + +// AskEnd ends an ask: with what the desk sealed to its key, which the vault opens and keeps as typed, or with +// the outcome the desk answered instead. How it ended is recorded whatever happens. +func (v *Vault) AskEnd(caller, call, sealed, outcome string) (Changed, error) { + if err := fromTheController(caller, "secret-ask"); err != nil { + return Changed{}, err + } + if sealed == "" && !askOutcomes[outcome] { + return Changed{}, fmt.Errorf("an ask ends with what was sealed to its key, or with one of dismissed, " + + "timed-out, empty, refused or failed") + } + c, err := v.takeCall("secret-ask", call) + if err != nil { + return Changed{}, err + } + end := func(how string) { + if err := v.recordAsk(AskRecord{ID: c.ask.ID, At: v.Now().UTC(), Event: AskEnded, Secret: c.secret, + Outcome: how}); err != nil { + v.log("the end of ask %s (%s) could not be recorded: %v", c.ask.ID, how, err) + } + } + if sealed == "" { + end(outcome) + return Changed{Name: c.secret, Unchanged: true}, nil + } + opened, err := c.open(sealed) + if err != nil { + end("failed") + return Changed{}, err + } + value := strings.TrimRight(string(opened), "\r\n") + zero(opened) + if strings.TrimSpace(value) == "" { + end("empty") + return Changed{}, fmt.Errorf("nothing was given: the prompt on %s was answered empty", c.ask.Desk) + } + changed, err := v.keep(c.secret, value, c.ask.AskedBy, caller, OriginTyped) + value = "" + if err != nil { + end("failed") + return Changed{}, err + } + end("given") + return changed, nil +} + +func (v *Vault) recordAsk(r AskRecord) error { + rolled, err := v.Store.RecordAsk(r) + if rolled { + v.log("THE RECORD OF ASKS WAS ROLLED: it reached its bound of %d bytes; the oldest of %d kept files is "+ + "dropped. Many asks in little time is worth a look: secret_asks", v.Store.HandedBound(), HandedKept) + } + return err +} + +// askedByName is who asked as the prompt and the record name it: the first clause of what the bus said, in the +// characters a name has, at most 80 (the controller's own rule, ADR 0277 §26), so no words of the caller's own +// reach the prompt. +func askedByName(caller string) string { + first, _, _ := strings.Cut(caller, ",") + var b strings.Builder + for _, r := range strings.TrimSpace(first) { + if b.Len() >= 80 { + break + } + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@', + r == '-', r == ' ': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + name := strings.TrimSpace(b.String()) + if name == "" || strings.HasPrefix(name, "-") { + return "an unnamed caller" + } + return name +} + +// ---- issued ------------------------------------------------------------------------------------------------- + +// IssuedOpen opens a call to take the credential a provider's software issued for a provision (ADR 0349 clause +// 10, ADR 0305 with the vault in the controller's place): the controller asks the provider's tool, on the +// provider's machine, with the call's key, and passes on what it answered. +func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, error) { + if err := fromTheController(caller, "issued"); err != nil { + return Opened{}, err + } + secret, err := ProvisionSecret(provider, provision) + if err != nil { + return Opened{}, err + } + if !namePart.MatchString(module) { + return Opened{}, fmt.Errorf("the provider's module %q is not a name the mesh writes", module) + } + id, key, err := v.openCall("issued", secret, module+" on "+provider, nil) + if err != nil { + return Opened{}, err + } + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// IssuedTake opens what the provider's tool sealed to the call's key and keeps it as the provision's one +// credential. The same value again writes nothing (ADR 0305 §5): every holder that has it is left as it is. +func (v *Vault) IssuedTake(caller, call, sealed string) (Changed, error) { + if err := fromTheController(caller, "issued"); err != nil { + return Changed{}, err + } + c, err := v.takeCall("issued", call) + if err != nil { + return Changed{}, err + } + opened, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + value := strings.TrimSpace(string(opened)) + zero(opened) + if value == "" { + return Changed{}, fmt.Errorf("%s answered an empty value; nothing was taken", c.by) + } + return v.keep(c.secret, value, c.by, caller, OriginIssued) +} + +// ---- rotate ------------------------------------------------------------------------------------------------- + +// Rotate makes a secret anew (ADR 0114, ADR 0228). Refused: a secret the vault does not hold; a value a +// provider's software issued, which the software rotates (ADR 0305 §9); and a provision's accepted credential, +// which the vault does not remake (ADR 0349 clause 8, ADR 0158 §8). A module's own value given by hand is +// rotated like a made one (ADR 0228). Who asked is the caller the runtime proved, or the person the controller +// names when the controller carries the act — both are kept. +func (v *Vault) Rotate(caller, secret, by string) (Changed, error) { + if caller == "" || caller == stdio.Unproven || !busUser.MatchString(caller) { + return Changed{}, errors.New("the vault rotates only for a caller the runtime proved") + } + if err := validName(secret); err != nil { + return Changed{}, err + } + rec, held, err := v.Store.Read(secret) + if err != nil { + return Changed{}, err + } + switch { + case !held: + return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s", secret) + case rec.Origin == OriginIssued: + return Changed{}, fmt.Errorf("not rotated: %s is issued by its provider's software, which the mesh cannot "+ + "make or rotate (ADR 0305 §9): change it in that software, and `secret issued` takes the value it holds now", + secret) + case rec.Origin == OriginAccepted && strings.HasPrefix(secret, "provision/"): + return Changed{}, fmt.Errorf("not rotated: %s was accepted, and an accepted credential is not remade by the "+ + "vault (ADR 0349 clause 8): accept its new value instead", secret) + } + if caller != ControllerCaller || by == "" { + by = caller + } + raw := make([]byte, 30) + if _, err := rand.Read(raw); err != nil { + return Changed{}, err + } + return v.keep(secret, base64.RawURLEncoding.EncodeToString(raw), by, caller, OriginMade) +} + +func someoneOr(by, caller string) string { + if strings.TrimSpace(by) == "" { + return caller + } + return by +}