Open a value only in the vault, sealed to a key made for its call (issue 675)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<node>/<module>/<name> a module's own secret on one machine
|
||||
// provision/<provider node>/<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
|
||||
}
|
||||
Reference in New Issue
Block a user