Files
mesh-catalog/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go
T

255 lines
10 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
"github.com/nats-io/nats.go"
)
// A module's secrets, from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647).
//
// **Asked for on request and reply, sealed to this client's key.** The client asks the vault's `get` in its own
// name, on the subject that names its account, so the vault knows from the bus's word which machine's client asks;
// it sends the public half of a key it made when it started and keeps in memory alone, and the vault answers the
// secret sealed to it (the sealed box of ADR 0206). Only the box crosses the bus. The value lives in this process
// while it fills the module's files, and reaches the machine only through the node-engine's local write socket.
// It is never written to a bucket, a report, an event or a log.
//
// **Asked once, and again when it changes.** A secret the client holds is asked again when the vault says it
// changed (`secret-changed`: which, by whom, its generation, never a value), so a rotation reaches the file
// without a new declaration; and every time the client starts or its node comes back, when it starts from nothing.
// A secret the vault does not hold yet, or a vault that does not answer, is waited for; a refusal fails the module
// with the vault's reason, and is asked again only after RefusedFor, so a refused client does not fill the vault's
// record of hand-outs every minute.
// VaultSeat is the vault's seat, and GetVerb the verb a client asks.
const (
VaultSeat = "mesh-vault"
GetVerb = "get"
)
// SecretChangedSubject is where the vault says a secret changed (mesh-controller link.EventSubject of mesh-vault's
// `secret-changed`): never a value.
const SecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed"
// RefusedFor is how long a refusal stands before the vault is asked again.
const RefusedFor = 10 * time.Minute
// VaultName is a module's own secret on one machine as the vault names it (mesh-vault intake.go OwnSecret).
func VaultName(node, module, name string) string { return "own/" + node + "/" + module + "/" + name }
// HolderName is the value a provider's software issued for one holder, a module on one machine, as the vault names
// it (mesh-vault perholder.go HolderSecret, issue 749).
func HolderName(provider, provision, node, module string) string {
return "provision/" + provider + "/" + provision + "/" + node + "/" + module
}
// callerToken is a bus user's name as the last token of a call it publishes, each dot written `~` (mesh-controller
// internal/broker CallerToken).
func callerToken(user string) string { return strings.ReplaceAll(user, ".", "~") }
// GetSubject is where this client asks the vault's `get` in its own name (mesh-controller internal/broker
// CalledSubject of `mesh.seat.mesh-vault.tool.get`).
func GetSubject(user string) string {
return "mesh.seat." + VaultSeat + ".call." + GetVerb + "." + callerToken(user)
}
// errNotYet is a secret the vault cannot hand yet: it holds none of that name, or it does not answer.
var errNotYet = errors.New("not yet")
// Secrets hands a module's secret, opened.
type Secrets interface {
// Get is the secret's value and generation, errNotYet (wrapped, with why) when it is to be waited for, or why
// it was refused.
Get(ctx context.Context, name string) (value string, generation int, err error)
}
// VaultSecrets asks the vault over the bus.
type VaultSecrets struct {
Conn *nats.Conn
// User is the client's own bus account, `provisioner.<node>`.
User string
// Key is the client's own key, made at start and never written down.
Key seal.KeyPair
// Within bounds one ask.
Within time.Duration
}
// NewVaultSecrets is the vault reached as user, with a key of its own made now.
func NewVaultSecrets(conn *nats.Conn, user string) (*VaultSecrets, error) {
key, err := seal.GenerateKeyPair()
if err != nil {
return nil, fmt.Errorf("the client could not make its key: %w", err)
}
return &VaultSecrets{Conn: conn, User: user, Key: key, Within: 15 * time.Second}, nil
}
// seatAnswer is a seat's answer on the bus (mesh-controller link.Answer).
type seatAnswer struct {
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// vaultAnswer is `get`'s result (mesh-vault vault.go Answer): never a value in the clear.
type vaultAnswer struct {
Name string `json:"name"`
Generation int `json:"generation"`
Sealed seal.Box `json:"sealed"`
}
// Get asks the vault for name, sealed to the client's key, and opens it.
func (v *VaultSecrets) Get(ctx context.Context, name string) (string, int, error) {
body, err := json.Marshal(map[string]string{"name": name, "public_key": v.Key.PublicKey})
if err != nil {
return "", 0, err
}
within := v.Within
if within <= 0 {
within = 15 * time.Second
}
asking, cancel := context.WithTimeout(ctx, within)
defer cancel()
msg, err := v.Conn.RequestWithContext(asking, GetSubject(v.User), body)
switch {
case errors.Is(err, nats.ErrNoResponders):
return "", 0, fmt.Errorf("%w: nothing answers the vault's get", errNotYet)
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
return "", 0, fmt.Errorf("%w: the vault did not answer within %s", errNotYet, within)
case err != nil:
return "", 0, fmt.Errorf("asking the vault: %w", err)
}
var a seatAnswer
if err := json.Unmarshal(msg.Data, &a); err != nil {
return "", 0, errors.New("the vault answered something that is not a seat's answer")
}
if a.Error != "" {
if strings.Contains(a.Error, "holds no secret named") {
return "", 0, fmt.Errorf("%w: the vault holds no %s yet", errNotYet, name)
}
return "", 0, fmt.Errorf("the vault refused it: %s", a.Error)
}
var got vaultAnswer
if err := json.Unmarshal(a.Result, &got); err != nil || got.Name != name {
return "", 0, fmt.Errorf("the vault answered something that is not %s", name)
}
value, err := seal.Open(got.Sealed, v.Key.PrivateKey)
if err != nil {
return "", 0, fmt.Errorf("the vault's answer for %s does not open with the client's key: %w", name, err)
}
return value, got.Generation, nil
}
// heldSecret is a secret as one run of the client holds it: its value, or why it was refused and when.
type heldSecret struct {
value string
generation int
refused string
refusedAt time.Time
}
// changedSecret is what `secret-changed` says, as far as the client reads it.
type changedSecret struct {
Name string `json:"name"`
Generation int `json:"generation"`
}
// secretOf is a module's secret, from what the run holds or from the vault: its value, or what it waits for, or
// why it cannot be filled.
func (c *Client) secretOf(ctx context.Context, s *state, module string, p Plan, n Need) (value, wait, fail string) {
name := ""
if slices.ContainsFunc(p.Secrets, func(sec PlannedSecret) bool { return sec.Name == n.Name }) {
name = VaultName(c.Node, module, n.Name)
} else if i := slices.IndexFunc(p.Provisions, func(pr PlannedProvision) bool { return pr.Name == n.Name }); i >= 0 {
// A credential of what the module requires, issued for this module on this machine (issue 757).
pr := p.Provisions[i]
name = HolderName(pr.Provider, pr.Provision, c.Node, module)
if pr.CredentialForm == "pair" {
// This consumer's own login from a provider that gives each consumer one (issue 779, ADR 0158 §11).
name = PairName(pr.Provider, pr.Provision, c.Node, module, pr.Local)
}
} else {
return "", "", fmt.Sprintf("%s: the plan names no secret %s, and the vault hands only what this machine's "+
"declaration names", n, n.Name)
}
return c.vaultValue(ctx, s, n, name)
}
// vaultValue is the vault's secret name for the need n, from what the run holds or from the vault: its value, or what
// it waits for, or why it cannot be filled.
func (c *Client) vaultValue(ctx context.Context, s *state, n Need, name string) (value, wait, fail string) {
if c.Secrets == nil {
return "", "", n.String() + ": this client has no way to ask the vault"
}
if h, ok := s.secrets[name]; ok {
if h.refused == "" {
return h.value, "", ""
}
if c.now().Sub(h.refusedAt) < RefusedFor {
return "", "", fmt.Sprintf("%s: %s", n, h.refused)
}
}
got, generation, err := c.Secrets.Get(ctx, name)
switch {
case errors.Is(err, errNotYet):
delete(s.secrets, name)
return "", fmt.Sprintf("%s (%s)", n, strings.TrimPrefix(err.Error(), errNotYet.Error()+": ")), ""
case err != nil:
s.secrets[name] = heldSecret{refused: err.Error(), refusedAt: c.now()}
return "", "", fmt.Sprintf("%s: %v", n, err)
}
s.secrets[name] = heldSecret{value: got, generation: generation}
return got, "", ""
}
// secretChanged folds what the vault said changed into the state: a secret this run holds is asked again. It
// answers the module whose secret it is, or nothing.
func (c *Client) secretChanged(s *state, body []byte) []string {
var ch changedSecret
if err := json.Unmarshal(body, &ch); err != nil {
return nil
}
parts := strings.Split(ch.Name, "/")
var module string
switch {
case len(parts) == 4 && parts[0] == "own" && parts[1] == c.Node:
module = parts[2]
case len(parts) == 5 && parts[0] == "provision" && parts[3] == c.Node:
// A value issued for one of this machine's modules (issue 757).
module = parts[4]
case (len(parts) == 5 || len(parts) == 6) && parts[0] == "pair" && (parts[1] == c.Node || parts[3] == c.Node):
// A consumer's own credential (ADR 0373): from a provider on this machine, every provider here listing its
// consumers is filled again, which asks for it if it lists that consumer, held or waited for; and for a module
// on this machine, the module whose plan names it as its file's credential (issue 779).
if h, held := s.secrets[ch.Name]; held && h.refused == "" && h.generation >= ch.Generation {
return nil
}
delete(s.secrets, ch.Name)
var out []string
if parts[1] == c.Node {
out = sortedKeys(s.providing)
}
if parts[3] == c.Node && hasPlan(s, parts[4]) && !slices.Contains(out, parts[4]) {
out = append(out, parts[4])
}
return out
default:
return nil
}
if !hasPlan(s, module) {
return nil
}
h, held := s.secrets[ch.Name]
if held && h.refused == "" && h.generation >= ch.Generation {
return nil
}
delete(s.secrets, ch.Name)
return []string{module}
}