255 lines
10 KiB
Go
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}
|
|
}
|