Vault: enrolment token and node account, signing for enrolment, a node's two logins and their rotation, own hand-out of a login
The vault serves the verbs enrolment-token, node-account, rotate-node, node-account-confirm and abandon, keeps the state node-logins, and says rotation-begun, node-account-ready and rotation-done (ADR 0587 §3, §5, §8; ADR 0583 §1-§7 and insights; ADR 0561 §4, §5). Only mesh-enrolment asks the first two and sign for an enrolment; a rotation needs a hand act read back from mesh-hand-acts by its id, runs one at a time with the bus node last, and is ready only when the loaded list holds the new login and done when it lacks the old one. A node's login is handed to that node's engine alone, sealed to the key it stated. The key of a node that leaves the placement is deleted. Accounts kept for an enrolment or a node are marked and never retired as unwanted. Subtasks 774/S43 to S50 in one commit: they share enrolment.go, noderotation.go, tools.go and the manifest, and could not be cut apart without a commit that does not build. New tests: 3 unit functions (table-style flows) and the one integration test of S50; mesh-vault stays at 134 unit and 2 integration tests, within the module budget of 500 and 50, so none was merged or deleted. The integration test's player gained a mesh/ask answer. (issue 774, 774/S43, 774/S44, 774/S45, 774/S46, 774/S47, 774/S48, 774/S49, 774/S50) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -55,8 +55,20 @@ type AccountHash struct {
|
||||
Node string `json:"node,omitempty"`
|
||||
// Rotating is a rotation under way over a second login (rotation.go, ADR 0561 §4): absent when none is.
|
||||
Rotating *Rotating `json:"rotating,omitempty"`
|
||||
// Kept marks an account kept for an enrolment or a node's login (enrolment.go, noderotation.go): `enrolment` for a
|
||||
// token's user `enrol.<node>`, `node` for a node's login. Such a key is dropped by the verb that ends it, never
|
||||
// retired as an account no longer wanted (retire.go).
|
||||
Kept string `json:"kept,omitempty"`
|
||||
// KeysDigest is, for a joining node's login, the digest of the keys it was minted for (ADR 0587 §3).
|
||||
KeysDigest string `json:"keys-digest,omitempty"`
|
||||
}
|
||||
|
||||
// The kinds of account the vault keeps beside the wanted ones (AccountHash.Kept).
|
||||
const (
|
||||
KeptEnrolment = "enrolment"
|
||||
KeptNode = "node"
|
||||
)
|
||||
|
||||
// AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone.
|
||||
type AccountStore interface {
|
||||
Get(user string) (*AccountHash, error)
|
||||
|
||||
@@ -137,6 +137,14 @@ func (v *Vault) adopt(node string, raw []byte, by string, force bool) (Adoption,
|
||||
return Adoption{}, err
|
||||
}
|
||||
|
||||
// The users the loaded list holds, for a node's rotation to see its new login in it and the old one gone (ADR 0583 §3).
|
||||
loaded := make(map[string]string, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
loaded[acc.User] = acc.Hash
|
||||
}
|
||||
v.loadedListHeld(h.From, loaded)
|
||||
defer v.advanceRotations()
|
||||
|
||||
v.hashMu.Lock()
|
||||
defer v.hashMu.Unlock()
|
||||
held, err := v.heldUsers()
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
package main
|
||||
|
||||
// The vault's part of enrolment (novox/hq ADR 0587 §3, §5, §8; ADR 0561 §4, §5; issue 774).
|
||||
//
|
||||
// A machine joins with a token a person types. The seat mesh-enrolment decides who may join; the vault holds the secrets.
|
||||
// Its verb `enrolment-token` makes the token's one-time secret, keeps it with its operator copy as `enrol/<node>/token`,
|
||||
// writes its bcrypt hash under `bus-accounts` as the user `enrol.<node>`, and answers at issue the public fields alone
|
||||
// (never the token). Asked to show it, it answers the whole token sealed to the operator key, which the operator opens at
|
||||
// their own terminal (`nox enrol open`); asked to drop it, it takes the token and its hash away. Its verb `node-account`
|
||||
// makes the account `node.<node>` of a joining machine at the redeem: once for a node and a set of keys, sealed to the key
|
||||
// the machine presented, with the node's key in `node-logins` naming that login in use and nothing rotating. Only the
|
||||
// seat mesh-enrolment may call either: the runtime proves who asks.
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// The names enrolment gives: the module that holds the seat mesh-enrolment, the prefix of a token's bus user, and the
|
||||
// vault's state of each node's logins.
|
||||
const (
|
||||
EnrolmentModule = "mesh-enrolment"
|
||||
EnrolUserPrefix = "enrol."
|
||||
NodeLoginsState = "node-logins"
|
||||
// MeshHostModule is the module a node's own login secret is kept under (`own/<node>/mesh-host/<login>`, ADR 0583 §3).
|
||||
MeshHostModule = "mesh-host"
|
||||
)
|
||||
|
||||
// Events the vault says for enrolment's accounts are account-hashed (accounts.go), never carrying a hash or a value.
|
||||
|
||||
// enrolNode says node is a name a joining machine can have: one the mesh writes, with no dot, and not shaped like a
|
||||
// second login.
|
||||
func enrolNode(node string) error {
|
||||
if !namePart.MatchString(node) || strings.Contains(node, ".") || strings.Contains(node, SecondLoginSuffix) {
|
||||
return fmt.Errorf("%q is not a name a node can have: lower-case words, no dot, and not ending in %s", node, SecondLoginSuffix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrolSecret is the vault's name of a node's enrolment token.
|
||||
func enrolSecret(node string) string { return "enrol/" + node + "/token" }
|
||||
|
||||
// enrolSecretName says name is an enrolment token, which is handed to nobody: only shown, sealed to the operator key.
|
||||
func enrolSecretName(name string) bool { return strings.HasPrefix(name, "enrol/") }
|
||||
|
||||
// loginSecret is the vault's name of a node's login, the value its node-engine takes (ADR 0583 §3 insight).
|
||||
func loginSecret(node, login string) string {
|
||||
return "own/" + node + "/" + MeshHostModule + "/" + login
|
||||
}
|
||||
|
||||
// nodeLoginSecret says name is a node's login secret, and for which node and login. A node's login is handed to that
|
||||
// node's node-engine alone.
|
||||
func nodeLoginSecret(name string) (node, login string, ok bool) {
|
||||
parts := strings.Split(name, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" || parts[2] != MeshHostModule {
|
||||
return "", "", false
|
||||
}
|
||||
base := strings.TrimSuffix(parts[3], SecondLoginSuffix)
|
||||
if base != NodeEnginePrefix+parts[1] {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[1], parts[3], true
|
||||
}
|
||||
|
||||
// loginRefusal says why caller may not have the secret name when it is a node's login, or "": a node's login is the
|
||||
// node-engine of that node's alone (`node.<node>` or its second login), sealed to the key that machine stated.
|
||||
func (v *Vault) loginRefusal(caller, name string) (isLogin bool, why string) {
|
||||
node, _, ok := nodeLoginSecret(name)
|
||||
if !ok {
|
||||
return false, ""
|
||||
}
|
||||
engine, _, _ := strings.Cut(strings.TrimPrefix(caller, NodeEnginePrefix), SecondLoginSuffix)
|
||||
if !strings.HasPrefix(caller, NodeEnginePrefix) || engine != node {
|
||||
return true, fmt.Sprintf("%s is a login of %s's node-engine and is handed to that machine's engine alone (%s or its second login)",
|
||||
name, node, NodeEnginePrefix+node)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// loginKeyRefusal says why a node's login is not sealed to the key asked: it is sealed to the key the node's node-engine
|
||||
// stated (node-key) and to no other, or "".
|
||||
func (v *Vault) loginKeyRefusal(caller, publicKey string) string {
|
||||
node, _ := loginNode(caller)
|
||||
stated, had, err := v.Store.NodeKey(node)
|
||||
if err != nil {
|
||||
return "the key " + node + "'s node-engine stated could not be read: " + err.Error()
|
||||
}
|
||||
if !had {
|
||||
return "the vault holds no sealing key that " + node + "'s node-engine stated (node-key)"
|
||||
}
|
||||
want, err := publicPEM(stated)
|
||||
if err != nil {
|
||||
return "the key " + node + "'s node-engine stated is not usable: " + err.Error()
|
||||
}
|
||||
given, err := askerKey(publicKey)
|
||||
if err != nil || strings.TrimSpace(given) != strings.TrimSpace(want) {
|
||||
return "a node's login is sealed to the key its node-engine stated, and the key sent is another"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fromEnrolment refuses every caller but the seat mesh-enrolment, as the runtime proved it.
|
||||
func (v *Vault) fromEnrolment(caller, verb string) error {
|
||||
callers := v.EnrolCallers
|
||||
if callers == nil {
|
||||
callers = stdio.Callers{EnrolmentModule}
|
||||
}
|
||||
if !callers.Allows(caller) {
|
||||
if caller == "" {
|
||||
caller = stdio.Unproven
|
||||
}
|
||||
return fmt.Errorf("%s is asked by the seat %s alone, which decides who may join; %s is not it (novox/hq ADR 0587 §3)",
|
||||
verb, EnrolmentModule, caller)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenBase is what a token states beside its secret: where the bus is and the certificate to expect there, and the
|
||||
// machine's first tunnel when the token carries one.
|
||||
type TokenBase struct {
|
||||
Broker string `json:"broker"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// TokenTunnel is the joining machine's side of its first tunnel, in the wire format the node-engine parses.
|
||||
type TokenTunnel struct {
|
||||
Key string `json:"key"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
HubKey string `json:"hub_key"`
|
||||
HubEndpoint string `json:"hub_endpoint"`
|
||||
}
|
||||
|
||||
// EnrolToken is the token a person carries to a joining machine, in the wire format of the node-engine's parser
|
||||
// (mesh-host internal/identity.Token): version, node, the bus's address and certificate fingerprint, the vault's public
|
||||
// signing key, the one-time secret, and the tunnel when there is one.
|
||||
type EnrolToken struct {
|
||||
Version int `json:"v"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Broker string `json:"broker,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Signer []byte `json:"signer,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// Text is the token as a person types it: base64url of its JSON.
|
||||
func (t EnrolToken) Text() (string, error) {
|
||||
raw, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// EnrolmentTokenArgs are `enrolment-token`'s arguments: the node, when it expires (the seat's word, echoed), and which
|
||||
// of the three acts: issue (neither flag), show or drop.
|
||||
type EnrolmentTokenArgs struct {
|
||||
Node string
|
||||
Expires string
|
||||
Show bool
|
||||
Drop bool
|
||||
}
|
||||
|
||||
// EnrolmentTokenAnswer is what `enrolment-token` answers: the public fields at issue, the sealed token when shown. Never
|
||||
// a token in the clear.
|
||||
type EnrolmentTokenAnswer struct {
|
||||
Node string `json:"node"`
|
||||
User string `json:"user"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Expires string `json:"expires,omitempty"`
|
||||
// Sealed is the whole token sealed to the operator key (base64 of the sealed box), only when shown.
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
Dropped bool `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// EnrolmentToken answers caller's `enrolment-token`.
|
||||
func (v *Vault) EnrolmentToken(caller string, a EnrolmentTokenArgs) (EnrolmentTokenAnswer, error) {
|
||||
if err := v.fromEnrolment(caller, "enrolment-token"); err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
if err := enrolNode(a.Node); err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
if v.Accounts == nil {
|
||||
return EnrolmentTokenAnswer{}, errors.New("the vault has no state of bus accounts to write to")
|
||||
}
|
||||
switch {
|
||||
case a.Drop && a.Show:
|
||||
return EnrolmentTokenAnswer{}, errors.New("a token is shown or dropped, not both")
|
||||
case a.Drop:
|
||||
return v.dropEnrolmentToken(caller, a.Node)
|
||||
case a.Show:
|
||||
return v.showEnrolmentToken(caller, a.Node)
|
||||
}
|
||||
return v.issueEnrolmentToken(caller, a)
|
||||
}
|
||||
|
||||
// issueEnrolmentToken makes the secret afresh (an earlier token of the node is replaced), keeps it with its operator copy,
|
||||
// and writes its hash as the user `enrol.<node>`. Its answer carries the public fields alone.
|
||||
func (v *Vault) issueEnrolmentToken(caller string, a EnrolmentTokenArgs) (EnrolmentTokenAnswer, error) {
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
secret := base64.RawURLEncoding.EncodeToString(raw)
|
||||
name, user := enrolSecret(a.Node), EnrolUserPrefix+a.Node
|
||||
c, err := v.keepWith(name, secret, "the vault", caller, OriginMade, keepOptions{quiet: true})
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the token of %s is not kept: %w", a.Node, err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(secret), v.hashCost())
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
at := v.Now().UTC()
|
||||
fp := Fingerprint(secret)
|
||||
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Of: name, Generation: c.Generation, Fingerprint: fp,
|
||||
At: at, Kept: KeptEnrolment}); err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the hash of %s could not be written to %s: %w", user, BusAccountsState, err)
|
||||
}
|
||||
v.saidHashed(user, name, c.Generation, fp, at)
|
||||
_ = v.recordHanding(Handing{To: caller, Name: name, Generation: c.Generation, Outcome: "issued", Why: "enrolment token of " + a.Node})
|
||||
v.log("made the enrolment token of %s for %s (generation %d); its hash is the user %s", a.Node, caller, c.Generation, user)
|
||||
return EnrolmentTokenAnswer{Node: a.Node, User: user, Fingerprint: fp, Expires: a.Expires}, nil
|
||||
}
|
||||
|
||||
// showEnrolmentToken answers the whole token sealed to the operator key: the secret held, the bus's address and
|
||||
// certificate fingerprint, and the vault's public signing key.
|
||||
func (v *Vault) showEnrolmentToken(caller, node string) (EnrolmentTokenAnswer, error) {
|
||||
rec, held, err := v.Store.Read(enrolSecret(node))
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
if !held {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the vault holds no enrolment token of %s", node)
|
||||
}
|
||||
secret, err := openAtRest(rec, v)
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the token of %s does not open with the vault's key: %w", node, err)
|
||||
}
|
||||
operator, err := v.Store.OperatorKey()
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
if operator == "" {
|
||||
return EnrolmentTokenAnswer{}, errors.New("the vault has no operator key to seal the token to (ADR 0113 §22)")
|
||||
}
|
||||
base, err := v.tokenBase()
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
keys, err := v.keys()
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the token carries the vault's signing key, which is not held yet: %w", err)
|
||||
}
|
||||
text, err := EnrolToken{Version: 1, Node: node, Broker: base.Broker, Fingerprint: base.Fingerprint,
|
||||
Signer: []byte(keys.key.Public().(ed25519.PublicKey)), Secret: secret, Tunnel: base.Tunnel}.Text()
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
sealed, err := operatorSeal(operator, []byte(text))
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
_ = v.recordHanding(Handing{To: caller, Name: enrolSecret(node), Generation: rec.Generation, Outcome: "shown",
|
||||
Why: "sealed to the operator key"})
|
||||
return EnrolmentTokenAnswer{Node: node, User: EnrolUserPrefix + node, Sealed: sealed}, nil
|
||||
}
|
||||
|
||||
// tokenBase is what a token states of the bus, or why the vault knows none.
|
||||
func (v *Vault) tokenBase() (TokenBase, error) {
|
||||
if v.TokenBase == nil {
|
||||
return TokenBase{}, errors.New("the vault does not know the bus's address and certificate fingerprint, so it makes no token to show")
|
||||
}
|
||||
return v.TokenBase()
|
||||
}
|
||||
|
||||
// dropEnrolmentToken takes the token and its hash away, at every end of an enrolment (ADR 0587 §5). Nothing held is
|
||||
// answered as dropped nothing.
|
||||
func (v *Vault) dropEnrolmentToken(caller, node string) (EnrolmentTokenAnswer, error) {
|
||||
name, user := enrolSecret(node), EnrolUserPrefix+node
|
||||
out := EnrolmentTokenAnswer{Node: node, User: user}
|
||||
a, err := v.Accounts.Get(user)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if a != nil {
|
||||
if a.Kept != KeptEnrolment {
|
||||
return out, fmt.Errorf("%s is no enrolment token's user: nothing was dropped", user)
|
||||
}
|
||||
if err := v.Accounts.Delete(user); err != nil {
|
||||
return out, fmt.Errorf("the hash of %s could not be taken out of %s: %w", user, BusAccountsState, err)
|
||||
}
|
||||
out.Dropped = true
|
||||
}
|
||||
if _, held, err := v.Store.Read(name); err != nil {
|
||||
return out, err
|
||||
} else if held {
|
||||
if err := v.Store.Delete(name); err != nil {
|
||||
return out, fmt.Errorf("the token of %s could not be deleted: %w", node, err)
|
||||
}
|
||||
out.Dropped = true
|
||||
}
|
||||
if out.Dropped {
|
||||
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "dropped", Why: "enrolment token of " + node})
|
||||
v.log("dropped the enrolment token of %s and its hash, asked by %s", node, caller)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// saidHashed says account-hashed for a user whose key the vault wrote: never the hash or the value.
|
||||
func (v *Vault) saidHashed(user, of string, generation int, fingerprint string, at time.Time) {
|
||||
if v.Emit == nil {
|
||||
return
|
||||
}
|
||||
if err := v.Emit(AccountHashedEvent, map[string]any{"user": user, "of": of, "generation": generation,
|
||||
"fingerprint": fingerprint, "at": at.Format(time.RFC3339Nano)}); err != nil {
|
||||
v.log("the hash of %s is written, but %s could not be said: %v", user, AccountHashedEvent, err)
|
||||
}
|
||||
}
|
||||
|
||||
// NodeAccountArgs are `node-account`'s arguments: the joining node, the sealing key its machine presented (raw X25519 in
|
||||
// base64, or SPKI PEM), the digest of the keys it presented, and whether to retire the account instead.
|
||||
type NodeAccountArgs struct {
|
||||
Node string
|
||||
SealingKey string
|
||||
KeysDigest string
|
||||
Retire bool
|
||||
}
|
||||
|
||||
// NodeAccountAnswer is the account `node.<node>` sealed to the presented key, or that it was retired.
|
||||
type NodeAccountAnswer struct {
|
||||
Node string `json:"node"`
|
||||
User string `json:"user"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
Sealed *seal.Box `json:"sealed,omitempty"`
|
||||
Minted bool `json:"minted,omitempty"`
|
||||
Retired bool `json:"retired,omitempty"`
|
||||
}
|
||||
|
||||
// NodeAccount answers caller's `node-account`: the joining node's account, minted once for a node and a set of keys and
|
||||
// answered sealed to the key presented; or, with retire, taken away again at a revoke.
|
||||
func (v *Vault) NodeAccount(caller string, a NodeAccountArgs) (NodeAccountAnswer, error) {
|
||||
if err := v.fromEnrolment(caller, "node-account"); err != nil {
|
||||
return NodeAccountAnswer{}, err
|
||||
}
|
||||
if err := enrolNode(a.Node); err != nil {
|
||||
return NodeAccountAnswer{}, err
|
||||
}
|
||||
if v.Accounts == nil {
|
||||
return NodeAccountAnswer{}, errors.New("the vault has no state of bus accounts to write to")
|
||||
}
|
||||
user := NodeEnginePrefix + a.Node
|
||||
secret := loginSecret(a.Node, user)
|
||||
v.hashMu.Lock()
|
||||
defer v.hashMu.Unlock()
|
||||
was, err := v.Accounts.Get(user)
|
||||
if err != nil {
|
||||
return NodeAccountAnswer{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, user, err)
|
||||
}
|
||||
if a.Retire {
|
||||
return v.retireNodeAccount(caller, a.Node, user, secret, was)
|
||||
}
|
||||
if was != nil && (was.Kept != KeptNode || was.Adopted) {
|
||||
return NodeAccountAnswer{}, fmt.Errorf("%s already has an account the vault did not make for an enrolment: a node's account is never "+
|
||||
"minted anew (novox/hq ADR 0561 §4)", user)
|
||||
}
|
||||
if _, held, err := v.Store.Read(enrolSecret(a.Node)); err != nil {
|
||||
return NodeAccountAnswer{}, err
|
||||
} else if !held {
|
||||
return NodeAccountAnswer{}, fmt.Errorf("%s holds no enrolment token, so no enrolment is under way: no account is made", a.Node)
|
||||
}
|
||||
to, err := sealingPEM(a.SealingKey)
|
||||
if err != nil {
|
||||
return NodeAccountAnswer{}, err
|
||||
}
|
||||
if strings.TrimSpace(a.KeysDigest) == "" {
|
||||
return NodeAccountAnswer{}, errors.New("node-account names the digest of the keys the machine presented (`keys_digest`)")
|
||||
}
|
||||
minted := false
|
||||
if was == nil {
|
||||
if err := v.mintNodeAccount(caller, a.Node, user, secret, a.KeysDigest); err != nil {
|
||||
return NodeAccountAnswer{}, err
|
||||
}
|
||||
minted = true
|
||||
} else if was.KeysDigest != a.KeysDigest {
|
||||
return NodeAccountAnswer{}, fmt.Errorf("%s was made for another set of keys than the ones presented now: revoke the enrolment and issue it again", user)
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil || !held {
|
||||
return NodeAccountAnswer{}, fmt.Errorf("the vault holds no value of %s to answer: %v", user, err)
|
||||
}
|
||||
value, err := openAtRest(rec, v)
|
||||
if err != nil {
|
||||
return NodeAccountAnswer{}, fmt.Errorf("%s does not open with the vault's key: %w", secret, err)
|
||||
}
|
||||
sealed, err := seal.Seal(value, to)
|
||||
if err != nil {
|
||||
return NodeAccountAnswer{}, err
|
||||
}
|
||||
_ = v.recordHanding(Handing{To: caller, Name: secret, Generation: rec.Generation, Outcome: "handed", Why: "node account of a joining node, sealed to its presented key"})
|
||||
return NodeAccountAnswer{Node: a.Node, User: user, Generation: rec.Generation, Sealed: &sealed, Minted: minted}, nil
|
||||
}
|
||||
|
||||
// mintNodeAccount makes a joining node's account: its value kept as the login's own secret, its hash in bus-accounts, and
|
||||
// the node's key in node-logins naming it in use with nothing rotating.
|
||||
func (v *Vault) mintNodeAccount(caller, node, user, secret, digest string) error {
|
||||
value, password, err := v.accountValue(node, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := v.keepWith(secret, value, "the vault", caller, OriginMade, keepOptions{quiet: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("the account of %s is not kept: %w", node, err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), v.hashCost())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := v.Now().UTC()
|
||||
fp := Fingerprint(value)
|
||||
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Of: secret, Generation: c.Generation, Fingerprint: fp,
|
||||
At: at, Kept: KeptNode, KeysDigest: digest}); err != nil {
|
||||
return fmt.Errorf("the hash of %s could not be written to %s: %w", user, BusAccountsState, err)
|
||||
}
|
||||
v.saidHashed(user, secret, c.Generation, fp, at)
|
||||
if err := v.putLogin(NodeLogin{Node: node, Login: user, Since: at}); err != nil {
|
||||
return fmt.Errorf("the account of %s is made, and %s %s could not be written: %w", node, NodeLoginsState, node, err)
|
||||
}
|
||||
v.log("made the account %s of a joining node for %s", user, caller)
|
||||
return nil
|
||||
}
|
||||
|
||||
// accountValue is a node's login whole, in the shape the controller's accounts have (url, fingerprint, node, user,
|
||||
// password), and its password.
|
||||
func (v *Vault) accountValue(node, user string) (value, password string, err error) {
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
password = base64.RawURLEncoding.EncodeToString(raw)
|
||||
acct := map[string]string{"node": node, "user": user, "password": password}
|
||||
if v.TokenBase != nil {
|
||||
if base, err := v.TokenBase(); err == nil {
|
||||
acct["url"], acct["fingerprint"] = base.Broker, base.Fingerprint
|
||||
}
|
||||
}
|
||||
out, err := json.Marshal(acct)
|
||||
return string(out), password, err
|
||||
}
|
||||
|
||||
// retireNodeAccount takes a joining node's account away: its hash, its value and its key in node-logins. Only one the
|
||||
// vault made for an enrolment, and only while nothing rotates.
|
||||
func (v *Vault) retireNodeAccount(caller, node, user, secret string, was *AccountHash) (NodeAccountAnswer, error) {
|
||||
out := NodeAccountAnswer{Node: node, User: user}
|
||||
if was != nil && was.Kept != KeptNode {
|
||||
return out, fmt.Errorf("%s is not an account the vault made for an enrolment: nothing was retired", user)
|
||||
}
|
||||
if nl, err := v.getLogin(node); err != nil {
|
||||
return out, err
|
||||
} else if nl != nil && nl.Rotating != "" {
|
||||
return out, fmt.Errorf("%s is rotating its login: nothing was retired", node)
|
||||
}
|
||||
if was != nil {
|
||||
if err := v.Accounts.Delete(user); err != nil {
|
||||
return out, fmt.Errorf("the hash of %s could not be taken out of %s: %w", user, BusAccountsState, err)
|
||||
}
|
||||
out.Retired = true
|
||||
}
|
||||
if _, held, err := v.Store.Read(secret); err != nil {
|
||||
return out, err
|
||||
} else if held && was != nil {
|
||||
if err := v.Store.Delete(secret); err != nil {
|
||||
return out, fmt.Errorf("the value of %s could not be deleted: %w", user, err)
|
||||
}
|
||||
}
|
||||
if out.Retired {
|
||||
if err := v.deleteLogin(node); err != nil {
|
||||
return out, err
|
||||
}
|
||||
_ = v.recordHanding(Handing{To: caller, Name: secret, Outcome: "retired", Why: "node account of an enrolment revoked"})
|
||||
v.log("retired the account %s of a revoked enrolment, asked by %s", user, caller)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sealingPEM takes the key a machine presents to be sealed to, as raw X25519 in base64 or SPKI PEM, and gives PEM.
|
||||
func sealingPEM(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", errors.New("node-account names the sealing key the machine presented (`sealing_key`)")
|
||||
}
|
||||
if strings.Contains(key, "BEGIN") {
|
||||
return askerKey(key)
|
||||
}
|
||||
pem, err := publicPEM(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the sealing key presented is not a usable key: %w", err)
|
||||
}
|
||||
return pem, nil
|
||||
}
|
||||
|
||||
// TokenBaseFromFile reads what a token states of the bus from the vault's own account file (`{url, fingerprint}` as the
|
||||
// controller's accounts have), in the directory of the vault's state. It answers an error when there is none.
|
||||
func TokenBaseFromFile(stateDir string) func() (TokenBase, error) {
|
||||
return func() (TokenBase, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(stateDir, "broker"))
|
||||
if err != nil {
|
||||
return TokenBase{}, fmt.Errorf("the vault's own account is not readable, so the bus's address is not known: %w", err)
|
||||
}
|
||||
var acct struct {
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
if json.Unmarshal(raw, &acct) != nil || acct.URL == "" || acct.Fingerprint == "" {
|
||||
return TokenBase{}, errors.New("the vault's own account names no bus address and certificate fingerprint")
|
||||
}
|
||||
return TokenBase{Broker: acct.URL, Fingerprint: acct.Fingerprint}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// loginsState is the vault's state node-logins in memory; like the runtime it refuses a value naming a credential.
|
||||
type loginsState struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]NodeLogin
|
||||
}
|
||||
|
||||
func (l *loginsState) Get(node string) (*NodeLogin, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
n, ok := l.keys[node]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (l *loginsState) Put(n NodeLogin) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
raw, _ := json.Marshal(n)
|
||||
if field := credentialField(raw); field != "" {
|
||||
return fmt.Errorf("%s carries a field %q, which names a credential", n.Node, field)
|
||||
}
|
||||
if l.keys == nil {
|
||||
l.keys = map[string]NodeLogin{}
|
||||
}
|
||||
l.keys[n.Node] = n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *loginsState) Delete(node string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.keys, node)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *loginsState) Keys() ([]string, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
var out []string
|
||||
for k := range l.keys {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// enrolling is a vault that mints, with the lease handed away, a loaded user list at revision 570, the bus on novox, the
|
||||
// nodes g14, ace and novox placed, their existing logins adopted, and hand acts of person.jochen's.
|
||||
type enrolling struct {
|
||||
testVault
|
||||
accounts *fakeAccounts
|
||||
logins *loginsState
|
||||
acts map[string]HandActSeen
|
||||
}
|
||||
|
||||
func newEnrolling(t *testing.T) *enrolling {
|
||||
t.Helper()
|
||||
v := newVault(t, nil)
|
||||
e := &enrolling{testVault: v, accounts: &fakeAccounts{keys: map[string]AccountHash{}}, logins: &loginsState{keys: map[string]NodeLogin{}}, acts: map[string]HandActSeen{}}
|
||||
v.Accounts, v.Logins = e.accounts, e.logins
|
||||
v.Placement = placementsOf(t, map[string][]string{"novox": {"nats"}, "g14": {"web"}, "ace": {"shop"}})
|
||||
v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil)
|
||||
v.LoadedListHeard(570)
|
||||
v.TokenBase = func() (TokenBase, error) {
|
||||
return TokenBase{Broker: "tls://10.77.0.1:4222", Fingerprint: "sha256:bus"}, nil
|
||||
}
|
||||
v.ReadAct = func(id string) (*HandActSeen, error) {
|
||||
if a, ok := e.acts[id]; ok {
|
||||
return &a, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
for _, n := range []string{"g14", "ace", "novox"} {
|
||||
e.accounts.keys["node."+n] = AccountHash{User: "node." + n, Hash: "$2a$04$adopted", Adopted: true, At: time.Now()}
|
||||
pub, _ := aSealingKey(t)
|
||||
if err := v.recordNodeKey(n, pub, "node."+n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *enrolling) act(id, by string) {
|
||||
e.acts[id] = HandActSeen{ID: id, By: by, At: time.Now().Add(time.Hour)}
|
||||
}
|
||||
|
||||
// 774/S43/AC1-AC3, S45/AC1 (novox/hq ADR 0587 §3, §5, §6, §8): mesh-enrolment alone asks the vault for a token, a node's
|
||||
// account and an enrolment's signature; the token is made once with its hash in bus-accounts and named by secret_holders,
|
||||
// shown sealed to the operator key and never in the clear, and dropped with its hash; the account is the same for the same
|
||||
// keys; a revoke retires it.
|
||||
func TestTheVaultMakesAnEnrolmentsTokenAccountAndSignature(t *testing.T) {
|
||||
e := newEnrolling(t)
|
||||
if _, err := e.EnsureSigning(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Who may ask (S43/AC2, S45/AC1): the seat's module on any machine; nobody else, not even the controller or a node.
|
||||
for _, caller := range []string{"node.g14", "controller", "person.jochen", "novox.mesh-grants", "", "unproven"} {
|
||||
if _, err := e.EnrolmentToken(caller, EnrolmentTokenArgs{Node: "new1"}); err == nil || !strings.Contains(err.Error(), "mesh-enrolment") {
|
||||
t.Errorf("%q was answered an enrolment token: %v", caller, err)
|
||||
}
|
||||
if _, err := e.NodeAccount(caller, NodeAccountArgs{Node: "new1"}); err == nil {
|
||||
t.Errorf("%q was answered a node account", caller)
|
||||
}
|
||||
if _, err := e.Sign(caller, SignArgs{Kind: delivery.KindEnrolment, Node: "new1", Reply: "AAEC"}); err == nil {
|
||||
t.Errorf("%q had an enrolment signed", caller)
|
||||
}
|
||||
}
|
||||
// S43/AC1: the answer holds the public fields, the hash is bcrypt of the kept secret, and secret_holders names it.
|
||||
got, err := e.EnrolmentToken("novox.mesh-enrolment", EnrolmentTokenArgs{Node: "new1", Expires: "2026-11-02T12:00:00Z"})
|
||||
if err != nil || got.User != "enrol.new1" || got.Sealed != "" || got.Expires == "" {
|
||||
t.Fatalf("issue answered %+v, %v", got, err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
rec, held, _ := e.Store.Read("enrol/new1/token")
|
||||
secret, _ := openAtRest(rec, e.Vault)
|
||||
if !held || secret == "" || strings.Contains(string(raw), secret) || strings.Contains(strings.Join(e.bus.raw, " "), secret) {
|
||||
t.Fatalf("the token is held %v, and its secret is in an answer or an event: %s", held, raw)
|
||||
}
|
||||
key := e.accounts.keys["enrol.new1"]
|
||||
if key.Kept != KeptEnrolment || bcrypt.CompareHashAndPassword([]byte(key.Hash), []byte(secret)) != nil {
|
||||
t.Errorf("enrol.new1 is %+v: the hash is not bcrypt of the secret", key)
|
||||
}
|
||||
if held, _ := e.EnrolmentsHeld(); len(held) != 1 || held[0].Node != "new1" || held[0].User != "enrol.new1" {
|
||||
t.Errorf("secret_holders names %+v", held)
|
||||
}
|
||||
if _, err := e.Get("novox.mesh-enrolment", "enrol/new1/token", pemOf(t, mustPub(t))); err == nil {
|
||||
t.Error("an enrolment token was handed out by get")
|
||||
}
|
||||
// Shown: the whole token sealed to the operator key, which parses as the engine's token.
|
||||
shown, err := e.EnrolmentToken("novox.mesh-enrolment", EnrolmentTokenArgs{Node: "new1", Show: true})
|
||||
if err != nil || shown.Sealed == "" {
|
||||
t.Fatalf("show answered %+v, %v", shown, err)
|
||||
}
|
||||
text := openOperator(t, e.operatorPublic, e.operatorPrivate, shown.Sealed)
|
||||
var tok EnrolToken
|
||||
body, _ := base64.RawURLEncoding.DecodeString(text)
|
||||
signer, _ := e.keys()
|
||||
if err := json.Unmarshal(body, &tok); err != nil || tok.Version != 1 || tok.Secret != secret || tok.Node != "new1" ||
|
||||
tok.Broker != "tls://10.77.0.1:4222" || tok.Fingerprint != "sha256:bus" ||
|
||||
string(tok.Signer) != string(signer.key.Public().(ed25519.PublicKey)) {
|
||||
t.Errorf("the shown token is %+v, %v", tok, err)
|
||||
}
|
||||
|
||||
// S44/AC1, AC3: the same account for the same keys, minted once, sealed to the presented key; its node-logins key.
|
||||
pub, priv := aSealingKey(t)
|
||||
first, err := e.NodeAccount("novox.mesh-enrolment", NodeAccountArgs{Node: "new1", SealingKey: pub, KeysDigest: "sha256:k1"})
|
||||
if err != nil || !first.Minted || first.Sealed == nil {
|
||||
t.Fatalf("node-account answered %+v, %v", first, err)
|
||||
}
|
||||
again, err := e.NodeAccount("novox.mesh-enrolment", NodeAccountArgs{Node: "new1", SealingKey: pub, KeysDigest: "sha256:k1"})
|
||||
if err != nil || again.Minted || again.Generation != first.Generation {
|
||||
t.Fatalf("asked twice, node-account answered %+v, %v", again, err)
|
||||
}
|
||||
a, _ := seal.Open(*first.Sealed, priv)
|
||||
b, _ := seal.Open(*again.Sealed, priv)
|
||||
var acct struct{ User, Password string }
|
||||
if a != b || json.Unmarshal([]byte(a), &acct) != nil || acct.User != "node.new1" ||
|
||||
bcrypt.CompareHashAndPassword([]byte(e.accounts.keys["node.new1"].Hash), []byte(acct.Password)) != nil {
|
||||
t.Errorf("the account differs between asks, or its hash is not of its password: %s / %s", a, b)
|
||||
}
|
||||
if nl := e.logins.keys["new1"]; nl.Login != "node.new1" || nl.Rotating != "" || nl.Stage != "" {
|
||||
t.Errorf("node-logins new1 is %+v", nl)
|
||||
}
|
||||
if _, err := e.NodeAccount("novox.mesh-enrolment", NodeAccountArgs{Node: "new1", SealingKey: pub, KeysDigest: "sha256:other"}); err == nil {
|
||||
t.Error("another set of keys was answered the account")
|
||||
}
|
||||
if _, err := e.NodeAccount("novox.mesh-enrolment", NodeAccountArgs{Node: "g14", SealingKey: pub, KeysDigest: "sha256:k1"}); err == nil {
|
||||
t.Error("an existing node's account was minted anew (ADR 0561 §4)")
|
||||
}
|
||||
// S45/AC1: an enrolment's reply is signed for a node that is joining, under the enrolment context.
|
||||
sig, err := e.Sign("novox.mesh-enrolment", SignArgs{Kind: delivery.KindEnrolment, Node: "new1", Reply: base64.StdEncoding.EncodeToString([]byte("reply"))})
|
||||
ctx, _ := delivery.SignContext(delivery.KindEnrolment)
|
||||
raw64, _ := base64.StdEncoding.DecodeString(sig.Signature)
|
||||
if err != nil || !ed25519.Verify(signer.key.Public().(ed25519.PublicKey), append([]byte(ctx), "reply"...), raw64) {
|
||||
t.Errorf("the enrolment signature is %+v, %v", sig, err)
|
||||
}
|
||||
if _, err := e.Sign("novox.mesh-enrolment", SignArgs{Kind: delivery.KindEnrolment, Node: "g14", Reply: "AAEC"}); err == nil {
|
||||
t.Error("an enrolment was signed for a node that holds no token")
|
||||
}
|
||||
// S44/AC2: a retire at revoke takes the account's hash, value and key away; S43/AC3: a drop, the token and its hash.
|
||||
if r, err := e.NodeAccount("novox.mesh-enrolment", NodeAccountArgs{Node: "new1", Retire: true}); err != nil || !r.Retired {
|
||||
t.Fatalf("retire answered %+v, %v", r, err)
|
||||
}
|
||||
if _, ok := e.accounts.keys["node.new1"]; ok || e.logins.keys["new1"].Node != "" {
|
||||
t.Error("the retired account's hash or node-logins key remains")
|
||||
}
|
||||
if _, held, _ := e.Store.Read("own/new1/mesh-host/node.new1"); held {
|
||||
t.Error("the retired account's value remains")
|
||||
}
|
||||
if d, err := e.EnrolmentToken("novox.mesh-enrolment", EnrolmentTokenArgs{Node: "new1", Drop: true}); err != nil || !d.Dropped {
|
||||
t.Fatalf("drop answered %+v, %v", d, err)
|
||||
}
|
||||
if _, ok := e.accounts.keys["enrol.new1"]; ok {
|
||||
t.Error("the token's hash remains after a drop")
|
||||
}
|
||||
if _, held, _ := e.Store.Read("enrol/new1/token"); held {
|
||||
t.Error("the token remains after a drop")
|
||||
}
|
||||
if held, _ := e.EnrolmentsHeld(); len(held) != 0 {
|
||||
t.Errorf("secret_holders still names %+v", held)
|
||||
}
|
||||
}
|
||||
|
||||
func mustPub(t *testing.T) string {
|
||||
t.Helper()
|
||||
pub, _ := aSealingKey(t)
|
||||
return pub
|
||||
}
|
||||
|
||||
// 774/S46/AC1-AC3, S47/AC1-AC2, S48/AC1, S49/AC1 (novox/hq ADR 0583 §1 to §7): a rotation is answered only for a hand act
|
||||
// read back by its id; one runs at a time and the bus node waits for the rest; the new login is ready only when the loaded
|
||||
// list holds it; a confirm from any caller but the new login retires nothing; a proven confirm removes the old hash and the
|
||||
// rotation is done once the loaded list lacks it; an abandon keeps the old hash; and a node's login is its engine's alone.
|
||||
func TestANodesLoginRotatesOneAtATimeAndEndsWithTheListWithoutTheOldOne(t *testing.T) {
|
||||
e := newEnrolling(t)
|
||||
ask := func(node, act string) (RotateNodeAnswer, error) {
|
||||
return e.RotateNode("person.jochen", RotateNodeArgs{Node: node, Why: "a rotation under test", Act: act})
|
||||
}
|
||||
// AC1: no recorded act, another caller's act, and an old act are all refused.
|
||||
e.acts["theirs"] = HandActSeen{ID: "theirs", By: "person.other", At: time.Now().Add(time.Hour)}
|
||||
e.acts["old"] = HandActSeen{ID: "old", By: "person.jochen", At: time.Unix(0, 0)}
|
||||
for _, act := range []string{"", "missing", "theirs", "old"} {
|
||||
if _, err := ask("g14", act); err == nil {
|
||||
t.Errorf("act %q warranted a rotation", act)
|
||||
}
|
||||
}
|
||||
if len(e.logins.keys) != 0 {
|
||||
t.Fatalf("a refused rotation wrote %+v", e.logins.keys)
|
||||
}
|
||||
e.act("a1", "person.jochen")
|
||||
e.act("a2", "person.jochen")
|
||||
e.act("a3", "person.jochen")
|
||||
first, err := ask("g14", "a1")
|
||||
if err != nil || first.Queued || first.Rotating != "node.g14--b" || first.Stage != StageBegun {
|
||||
t.Fatalf("g14 began as %+v, %v", first, err)
|
||||
}
|
||||
// The second login has a hash of its own value, the first is marked rotating, and the value waits for the engine.
|
||||
second := e.accounts.keys["node.g14--b"]
|
||||
rec, held, _ := e.Store.Read("own/g14/mesh-host/node.g14--b")
|
||||
value, _ := openAtRest(rec, e.Vault)
|
||||
var acct struct{ User, Password string }
|
||||
if !held || json.Unmarshal([]byte(value), &acct) != nil || acct.User != "node.g14--b" ||
|
||||
bcrypt.CompareHashAndPassword([]byte(second.Hash), []byte(acct.Password)) != nil || e.accounts.keys["node.g14"].Rotating == nil {
|
||||
t.Fatalf("g14's second login is held %v as %s, keyed %+v", held, value, second)
|
||||
}
|
||||
// AC2: one at a time, the bus node last.
|
||||
ace, err := ask("ace", "a2")
|
||||
if err != nil || !ace.Queued || strings.Join(ace.Behind, ",") != "g14" {
|
||||
t.Fatalf("ace asked while g14 runs: %+v, %v", ace, err)
|
||||
}
|
||||
bus, err := ask("novox", "a3")
|
||||
if err != nil || !bus.Queued || strings.Join(bus.Behind, ",") != "g14,ace" {
|
||||
t.Fatalf("novox, the bus node, asked: %+v, %v", bus, err)
|
||||
}
|
||||
if _, err := ask("g14", "a1"); err == nil {
|
||||
t.Error("g14 began a rotation twice")
|
||||
}
|
||||
// AC3: the loaded list lacks the second login, so nothing is ready; once it holds it, node-account-ready is said.
|
||||
e.loadedListHeld("sha256:l1", map[string]string{"node.g14": "$2a$04$adopted"})
|
||||
e.advanceRotations()
|
||||
if said(e.bus, NodeAccountReadyEvent) != 0 || e.logins.keys["g14"].Stage != StageBegun {
|
||||
t.Fatalf("ready without the login in the loaded list: %+v", e.logins.keys["g14"])
|
||||
}
|
||||
e.loadedListHeld("sha256:l2", map[string]string{"node.g14": "$2a$04$adopted", "node.g14--b": second.Hash})
|
||||
e.advanceRotations()
|
||||
if said(e.bus, NodeAccountReadyEvent) != 1 || e.logins.keys["g14"].Stage != StageReady {
|
||||
t.Fatalf("not ready with the login in the loaded list: %+v", e.logins.keys["g14"])
|
||||
}
|
||||
// S49/AC1: the new login's value is g14's engine's alone, sealed to the key it stated.
|
||||
stated, _, _ := e.Store.NodeKey("g14")
|
||||
statedPEM, _ := publicPEM(stated)
|
||||
if a, err := e.Get("node.g14", "own/g14/mesh-host/node.g14--b", statedPEM); err != nil || a.Sealed.Ct == "" {
|
||||
t.Errorf("g14's engine was answered %+v, %v", a, err)
|
||||
}
|
||||
if _, err := e.Get("node.ace", "own/g14/mesh-host/node.g14--b", statedPEM); err == nil || !strings.Contains(err.Error(), "engine") {
|
||||
t.Errorf("ace's engine asked for g14's login: %v", err)
|
||||
}
|
||||
if _, err := e.Get("node.g14", "own/g14/mesh-host/node.g14--b", pemOf(t, mustPub(t))); err == nil {
|
||||
t.Error("g14's login was sealed to a key its engine did not state")
|
||||
}
|
||||
if own, err := e.OwnOfNode("node.ace"); err != nil || len(own.Own) != 0 {
|
||||
t.Errorf("ace's engine was handed %+v, %v", own, err)
|
||||
}
|
||||
// S47/AC1: a confirm from the old login, from another node's login or from a person retires nothing.
|
||||
for _, caller := range []string{"node.g14", "node.ace--b", "person.jochen", "controller"} {
|
||||
if _, err := e.NodeAccountConfirm(caller); err == nil {
|
||||
t.Errorf("%q confirmed g14's rotation", caller)
|
||||
}
|
||||
}
|
||||
if _, ok := e.accounts.keys["node.g14"]; !ok || e.logins.keys["g14"].Stage != StageReady {
|
||||
t.Fatalf("a refused confirm retired something: %+v", e.logins.keys["g14"])
|
||||
}
|
||||
// S47/AC2: the proven confirm removes the old hash; done only when the loaded list lacks it.
|
||||
if c, err := e.NodeAccountConfirm("node.g14--b"); err != nil || c.Stage != StageConfirmed {
|
||||
t.Fatalf("the confirm answered %+v, %v", c, err)
|
||||
}
|
||||
if _, ok := e.accounts.keys["node.g14"]; ok || said(e.bus, RotationDoneEvent) != 0 {
|
||||
t.Fatal("the old hash remains, or the rotation was done while the loaded list still held it")
|
||||
}
|
||||
e.loadedListHeld("sha256:l3", map[string]string{"node.g14--b": second.Hash})
|
||||
e.advanceRotations()
|
||||
if nl := e.logins.keys["g14"]; said(e.bus, RotationDoneEvent) != 1 || nl.Login != "node.g14--b" || nl.Rotating != "" || nl.Stage != "" {
|
||||
t.Fatalf("after the loaded list lacks the old login g14 is %+v", nl)
|
||||
}
|
||||
if _, held, _ := e.Store.Read("own/g14/mesh-host/node.g14"); held {
|
||||
t.Error("the old login's value remains after the rotation is done")
|
||||
}
|
||||
if _, err := e.AbandonRotation("person.jochen", "g14"); err == nil {
|
||||
t.Error("a finished rotation was abandoned")
|
||||
}
|
||||
// The queue moves on, the bus node after every other: ace first; novox only once ace is done.
|
||||
e.RotationPass()
|
||||
if e.logins.keys["ace"].Rotating == "" || e.logins.keys["novox"].Rotating != "" {
|
||||
t.Fatalf("after g14 the queue began ace=%+v novox=%+v", e.logins.keys["ace"], e.logins.keys["novox"])
|
||||
}
|
||||
e.RotationPass()
|
||||
if e.logins.keys["novox"].Rotating != "" {
|
||||
t.Fatal("the bus node began while ace still rotated")
|
||||
}
|
||||
// S48/AC1: an abandon drops the new login and its value, keeps the old hash and clears the rotating part.
|
||||
if _, err := e.AbandonRotation("person.jochen", "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := e.accounts.keys["node.ace--b"]; ok || e.accounts.keys["node.ace"].Rotating != nil || e.accounts.keys["node.ace"].Hash == "" ||
|
||||
e.logins.keys["ace"].Rotating != "" || e.logins.keys["ace"].Login != "node.ace" {
|
||||
t.Errorf("an abandon left ace as %+v / %+v", e.accounts.keys["node.ace"], e.logins.keys["ace"])
|
||||
}
|
||||
if _, held, _ := e.Store.Read("own/ace/mesh-host/node.ace--b"); held {
|
||||
t.Error("an abandoned login's value remains")
|
||||
}
|
||||
e.RotationPass()
|
||||
if e.logins.keys["novox"].Rotating == "" {
|
||||
t.Fatal("the bus node did not begin once every other was done")
|
||||
}
|
||||
}
|
||||
|
||||
// said counts the events of a kind the vault said.
|
||||
func said(p *published, event string) int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
n := 0
|
||||
for _, e := range p.events {
|
||||
if e["event"] == event {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 774/S48/AC2 (novox/hq ADR 0583 §1 insight): the key of a node that leaves the placement is deleted, heard from the
|
||||
// placement's watch or found by the minute pass; one that cannot be read deletes nothing.
|
||||
func TestANodesLoginsKeyGoesWhenItLeavesThePlacement(t *testing.T) {
|
||||
e := newEnrolling(t)
|
||||
for _, n := range []string{"g14", "gone", "gone2"} {
|
||||
e.logins.keys[n] = NodeLogin{Node: n, Login: "node." + n}
|
||||
}
|
||||
e.NodeLeft("g14")
|
||||
if _, ok := e.logins.keys["g14"]; !ok {
|
||||
t.Error("g14 is placed, and its key was deleted")
|
||||
}
|
||||
e.NodeLeft("gone")
|
||||
e.LoginsPass()
|
||||
if len(e.logins.keys) != 1 || e.logins.keys["g14"].Node == "" {
|
||||
t.Errorf("the keys after a node left and a pass: %+v", e.logins.keys)
|
||||
}
|
||||
e.logins.keys["gone"] = NodeLogin{Node: "gone"}
|
||||
e.Placement = PlacementReader{}
|
||||
e.LoginsPass()
|
||||
if _, ok := e.logins.keys["gone"]; !ok {
|
||||
t.Error("a placement that is not read deleted a key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
// The tools of enrolment and node rotation (novox/hq ADR 0587, ADR 0583): the seat's verbs the vault serves, and the
|
||||
// tools that read what it holds of them. Arguments are strings and booleans the runtime hands; the caller is the one the
|
||||
// runtime proved.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EnrolmentHeld is one enrolment token the vault holds, as secret_holders lists it: never the token or its hash.
|
||||
type EnrolmentHeld struct {
|
||||
Node string `json:"node"`
|
||||
User string `json:"user"`
|
||||
Generation int `json:"generation"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
At time.Time `json:"at"`
|
||||
// Account says the node's own account is made too (node-account answered).
|
||||
Account bool `json:"account_made"`
|
||||
}
|
||||
|
||||
// EnrolmentsHeld lists the enrolment tokens held, by node.
|
||||
func (v *Vault) EnrolmentsHeld() ([]EnrolmentHeld, error) {
|
||||
out := []EnrolmentHeld{}
|
||||
if v.Accounts == nil {
|
||||
return out, nil
|
||||
}
|
||||
users, err := v.Accounts.Users()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, u := range users {
|
||||
a, err := v.Accounts.Get(u)
|
||||
if err != nil || a == nil || a.Kept != KeptEnrolment {
|
||||
continue
|
||||
}
|
||||
node := u[len(EnrolUserPrefix):]
|
||||
n, _ := v.Accounts.Get(NodeEnginePrefix + node)
|
||||
out = append(out, EnrolmentHeld{Node: node, User: u, Generation: a.Generation, Fingerprint: a.Fingerprint, At: a.At,
|
||||
Account: n != nil && n.Kept == KeptNode})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func enrolmentTools(env *Env) []toolDef {
|
||||
node := str("the node's name")
|
||||
return []toolDef{
|
||||
{Name: Seat + ".enrolment-token",
|
||||
Description: "The enrolment token of a joining machine, for the seat mesh-enrolment alone (novox/hq ADR 0587 §3, §5, " +
|
||||
"§8). Issue (the default): makes the one-time secret, keeps it with its operator copy, writes its hash as the bus " +
|
||||
"user enrol.<node>, and answers the public fields, never the token. show: the whole token sealed to the operator " +
|
||||
"key, for the operator to open at their terminal. drop: the token and its hash gone, at every end of an enrolment.",
|
||||
Input: map[string]any{"node": node, "expires": str("when the token ends, as the seat states it (echoed)"),
|
||||
"show": map[string]any{"type": "boolean", "description": "answer the token sealed to the operator key"},
|
||||
"drop": map[string]any{"type": "boolean", "description": "drop the token and its hash"}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.EnrolmentToken(caller, EnrolmentTokenArgs{Node: arg(a, "node"), Expires: arg(a, "expires"),
|
||||
Show: isTrue(a["show"]), Drop: isTrue(a["drop"])})
|
||||
}},
|
||||
{Name: Seat + ".node-account",
|
||||
Description: "A joining node's account node.<node>, for the seat mesh-enrolment alone at the redeem (novox/hq ADR 0587 " +
|
||||
"§3): made once for a node and the keys it presented, answered sealed to the sealing key presented, with the " +
|
||||
"node's key in node-logins naming the login in use. Asked again for the same keys it answers the same account. " +
|
||||
"retire: the account, its value and its key taken away at a revoke.",
|
||||
Input: map[string]any{"node": node, "sealing_key": str("the machine's X25519 sealing key, raw in base64 or SPKI PEM"),
|
||||
"keys_digest": str("the digest of the keys the machine presented"),
|
||||
"retire": map[string]any{"type": "boolean", "description": "take the account away"}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.NodeAccount(caller, NodeAccountArgs{Node: arg(a, "node"), SealingKey: arg(a, "sealing_key"),
|
||||
KeysDigest: arg(a, "keys_digest"), Retire: isTrue(a["retire"])})
|
||||
}},
|
||||
{Name: Seat + ".rotate-node",
|
||||
Description: "Begin the rotation of a node's login (novox/hq ADR 0583 §2, §7): answered only for a call that carries a hand " +
|
||||
"act recorded through mesh-hand-acts, read back by its id. One rotation at a time mesh-wide: a call asked while " +
|
||||
"another runs is queued and begun when it is done, the bus node after every other. Makes the other login " +
|
||||
"(node.<n>--b or node.<n>), keeps its value for the node's engine, and says rotation-begun.",
|
||||
Input: map[string]any{"node": node, "why": str("why the login rotates"),
|
||||
"act": str("the id of the hand act recorded through mesh-hand-acts that warrants it")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.RotateNode(caller, RotateNodeArgs{Node: arg(a, "node"), Why: arg(a, "why"), Act: arg(a, "act")})
|
||||
}},
|
||||
{Name: Seat + ".node-account-confirm",
|
||||
Description: "A node-engine, dialled as its new login, says it connects with it (novox/hq ADR 0583 §3, §4): the runtime " +
|
||||
"proves the login, the old login's hash is removed, and rotation-done follows when the loaded list lacks it.",
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.NodeAccountConfirm(caller)
|
||||
}},
|
||||
{Name: Seat + ".abandon",
|
||||
Description: "Abandon a node's rotation before its confirmation (novox/hq ADR 0583 §5): the new login and its value are " +
|
||||
"dropped, the old hash kept, the rotating part of the node's key cleared; a rotation only asked leaves the queue.",
|
||||
Input: map[string]any{"node": node},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.AbandonRotation(caller, arg(a, "node"))
|
||||
}},
|
||||
{Name: "node_logins",
|
||||
Description: "Every node's key of the vault's state node-logins: the login its node-engine dials as and, while a rotation " +
|
||||
"runs, the other login and its stage; with the rotations queued and the enrolment tokens held. Never a value or a hash.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.Logins == nil {
|
||||
return nil, errors.New("the vault has no state node-logins")
|
||||
}
|
||||
keys, err := v.Logins.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
rows := []NodeLogin{}
|
||||
for _, k := range keys {
|
||||
if nl, err := v.Logins.Get(k); err == nil && nl != nil {
|
||||
rows = append(rows, *nl)
|
||||
}
|
||||
}
|
||||
held, _ := v.EnrolmentsHeld()
|
||||
return map[string]any{"logins": rows, "rotation": v.RotationState(), "enrolments": held}, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ func build() *Env {
|
||||
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
||||
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, LeaseRead: readLease, WantRead: readWant,
|
||||
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Placement: placement, Signers: busSigners{},
|
||||
AskCallers: askCallers(os.Getenv("MESH_VAULT_SECRET_ASK_CALLERS")),
|
||||
AskCallers: askCallers(os.Getenv("MESH_VAULT_SECRET_ASK_CALLERS")), EnrolCallers: askCallers(os.Getenv("MESH_VAULT_ENROLMENT_CALLERS")),
|
||||
Logins: busNodeLogins{}, ReadAct: readHandAct, TokenBase: TokenBaseFromFile(os.Getenv("MESH_VAULT_STATE")),
|
||||
Bound: Bound{
|
||||
Get: func(key string) ([]byte, error) {
|
||||
e, err := stdio.State(BoundState).Get(key)
|
||||
@@ -86,6 +87,46 @@ func build() *Env {
|
||||
return env
|
||||
}
|
||||
|
||||
// readHandAct reads a hand act back by its id through the seat mesh-hand-acts (the vault's manifest invokes it): nil when
|
||||
// the log holds none.
|
||||
func readHandAct(id string) (*HandActSeen, error) {
|
||||
raw, err := stdio.Ask("mesh-hand-acts.hand-acts", map[string]any{"days": 2})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var listing struct {
|
||||
Acts []HandActSeen `json:"acts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &listing); err != nil {
|
||||
return nil, fmt.Errorf("mesh-hand-acts answered what is not its listing: %w", err)
|
||||
}
|
||||
for _, a := range listing.Acts {
|
||||
if a.ID == id {
|
||||
return &a, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// watchLeavers deletes a node's key of node-logins when its node key of the placement is deleted (noderotation.go): a
|
||||
// watch, never a poll. It answers whether the watch began; the minute pass (LoginsPass) catches what it missed.
|
||||
func watchLeavers(env *Env) bool {
|
||||
if env.Vault == nil {
|
||||
return true
|
||||
}
|
||||
err := stdio.State(PlacementRead).Watch("*.node", func(c stdio.StateChange) error {
|
||||
if c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0 {
|
||||
env.Vault.NodeLeft(strings.TrimSuffix(c.Key, ".node"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
say("the placement's nodes are not watched for one that leaves; the minute pass finds it: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// assigned is the assignments mirror as the vault's watch keeps it.
|
||||
var assigned *WatchedAssignments
|
||||
|
||||
@@ -280,7 +321,11 @@ func main() {
|
||||
watched, watching := watchHandings(env), watchAssignments()
|
||||
lease := watchLease(env)
|
||||
wants := watchWants(env)
|
||||
leavers := watchLeavers(env)
|
||||
for {
|
||||
if !leavers {
|
||||
leavers = watchLeavers(env)
|
||||
}
|
||||
if !watched {
|
||||
watched = watchHandings(env)
|
||||
}
|
||||
@@ -304,6 +349,8 @@ func main() {
|
||||
env.Vault.HashOwed()
|
||||
env.Vault.MintOwed()
|
||||
env.Vault.RetirePass()
|
||||
env.Vault.RotationPass()
|
||||
env.Vault.LoginsPass()
|
||||
}
|
||||
env.pairPass()
|
||||
if env.Custody != nil {
|
||||
|
||||
@@ -60,6 +60,8 @@ type player struct {
|
||||
next int
|
||||
pending map[string]chan wire
|
||||
said []string
|
||||
// ask answers a mesh/ask (a call of another module's tool); nil answers none, as a call with no responder.
|
||||
ask func(key string, body json.RawMessage) (any, error)
|
||||
}
|
||||
|
||||
// bucketOf is the bucket a state names: the vault's own by its local name, another's as `<owner>.<name>`.
|
||||
@@ -215,8 +217,20 @@ func (p *player) handle(m wire) {
|
||||
p.said = append(p.said, e.Key+" "+string(e.Body))
|
||||
p.mu.Unlock()
|
||||
p.answer(m.ID, map[string]any{}, p.nc.Publish("mesh.mod.mesh-vault.event."+e.Key, e.Body))
|
||||
case "mesh/ask":
|
||||
var ask struct {
|
||||
Key string `json:"key"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &ask)
|
||||
if p.ask != nil {
|
||||
result, err := p.ask(ask.Key, ask.Body)
|
||||
p.answer(m.ID, result, err)
|
||||
return
|
||||
}
|
||||
p.answer(m.ID, nil, errors.New("nats: no responders available for request"))
|
||||
default:
|
||||
// mesh/ask and the rest: nothing answers on this bus, as a call with no responder.
|
||||
// the rest: nothing answers on this bus, as a call with no responder.
|
||||
p.answer(m.ID, nil, errors.New("nats: no responders available for request"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
package main
|
||||
|
||||
// A node's two logins and their rotation (novox/hq ADR 0583 §1 to §7 and its insights; issue 774).
|
||||
//
|
||||
// A node-engine dials the bus as `node.<n>` or as `node.<n>--b`, the two logins alternating. The vault's state
|
||||
// `node-logins` holds one key per node, `<n>`: the login in use, and while a rotation runs the other login with its stage
|
||||
// (begun, ready, confirmed). Each node-engine reads its own key alone (the bus grants it that read); the vault writes it
|
||||
// alone.
|
||||
//
|
||||
// `rotate-node` begins a rotation: answered only for a call that carries a hand act recorded through mesh-hand-acts, read
|
||||
// back by its id; one rotation at a time mesh-wide, a call asked meanwhile is queued and begun at the pass that finds the
|
||||
// first done; the bus node last, after every other node asked. It makes the other login's value, keeps it as
|
||||
// `own/<n>/mesh-host/<login>` (sealed, with the operator copy; the node-engine takes it through `own`, sealed to its own
|
||||
// key), writes its hash as the key `<login>` of bus-accounts and marks the login in use as rotating, so the grants part
|
||||
// composes both. `node-account-ready` is said only when the user list the bus node loaded holds the new login's hash.
|
||||
// `node-account-confirm` is the node-engine's, dialled as the new login, which the runtime proves: the old login's hash
|
||||
// is then removed, and `rotation-done` is said once the loaded list lacks it, when the old value is dropped and the
|
||||
// rotating part of the key cleared. `abandon` drops the new login and its value before the confirmation and keeps the
|
||||
// old hash. A key whose node leaves the placement is deleted.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// The events a rotation says, and the stages of a node's key.
|
||||
const (
|
||||
RotationBegunEvent = "rotation-begun"
|
||||
NodeAccountReadyEvent = "node-account-ready"
|
||||
RotationDoneEvent = "rotation-done"
|
||||
|
||||
StageBegun = "begun"
|
||||
StageReady = "ready"
|
||||
StageConfirmed = "confirmed"
|
||||
|
||||
// HandActWithin is how recent a hand act is that warrants a rotation.
|
||||
HandActWithin = 24 * time.Hour
|
||||
)
|
||||
|
||||
// NodeLogin is one key of the vault's state `node-logins`: the login the node's node-engine dials as, and, while a
|
||||
// rotation runs, the other login and how far it has come. Never a value or a hash.
|
||||
type NodeLogin struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
// Rotating is the other login while a rotation runs; empty reads as none.
|
||||
Rotating string `json:"rotating,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Since time.Time `json:"since,omitzero"`
|
||||
// ListRevision is the revision of the loaded user list the rotation began at.
|
||||
ListRevision uint64 `json:"list-revision,omitempty"`
|
||||
}
|
||||
|
||||
// NodeLoginStore is the vault's state `node-logins`, by node.
|
||||
type NodeLoginStore interface {
|
||||
Get(node string) (*NodeLogin, error)
|
||||
Put(n NodeLogin) error
|
||||
Delete(node string) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// busNodeLogins is NodeLoginStore over the vault's own state through its runtime.
|
||||
type busNodeLogins struct{}
|
||||
|
||||
func (busNodeLogins) Get(node string) (*NodeLogin, error) {
|
||||
e, err := stdio.State(NodeLoginsState).Get(node)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var n NodeLogin
|
||||
if err := json.Unmarshal(e.Value, &n); err != nil {
|
||||
return nil, fmt.Errorf("%s %s is not a node's login: %w", NodeLoginsState, node, err)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (busNodeLogins) Put(n NodeLogin) error {
|
||||
_, err := stdio.State(NodeLoginsState).Put(n.Node, n)
|
||||
return err
|
||||
}
|
||||
|
||||
func (busNodeLogins) Delete(node string) error { return stdio.State(NodeLoginsState).Delete(node) }
|
||||
|
||||
func (busNodeLogins) Keys() ([]string, error) { return stdio.State(NodeLoginsState).Keys() }
|
||||
|
||||
// HandActSeen is a hand act as the vault reads it back from mesh-hand-acts by its id: who recorded it and when.
|
||||
type HandActSeen struct {
|
||||
ID string `json:"id"`
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// ActReader reads a hand act back by its id: nil when mesh-hand-acts holds none.
|
||||
type ActReader func(id string) (*HandActSeen, error)
|
||||
|
||||
// queuedRotation is a `rotate-node` asked while another ran, begun at the pass that finds none running.
|
||||
type queuedRotation struct {
|
||||
Node string `json:"node"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
Act string `json:"act"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// nodeRotation is what the vault keeps in memory of its rotations: the user list the bus node loaded (user to hash), and
|
||||
// the rotations queued. The record is node-logins and bus-accounts; a restart forgets the queue, and the one who asked
|
||||
// asks again.
|
||||
type nodeRotation struct {
|
||||
mu sync.Mutex
|
||||
loaded map[string]string
|
||||
loadedFrom string
|
||||
queue []queuedRotation
|
||||
}
|
||||
|
||||
func (v *Vault) getLogin(node string) (*NodeLogin, error) {
|
||||
if v.Logins == nil {
|
||||
return nil, errors.New("the vault has no state " + NodeLoginsState + " to read")
|
||||
}
|
||||
return v.Logins.Get(node)
|
||||
}
|
||||
|
||||
func (v *Vault) putLogin(n NodeLogin) error {
|
||||
if v.Logins == nil {
|
||||
return errors.New("the vault has no state " + NodeLoginsState + " to write")
|
||||
}
|
||||
return v.Logins.Put(n)
|
||||
}
|
||||
|
||||
func (v *Vault) deleteLogin(node string) error {
|
||||
if v.Logins == nil {
|
||||
return nil
|
||||
}
|
||||
return v.Logins.Delete(node)
|
||||
}
|
||||
|
||||
// currentLogin is the login node dials as: its key's, or `node.<node>` before any key is written.
|
||||
func (v *Vault) currentLogin(node string) (string, error) {
|
||||
nl, err := v.getLogin(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if nl != nil && nl.Login != "" {
|
||||
return nl.Login, nil
|
||||
}
|
||||
return NodeEnginePrefix + node, nil
|
||||
}
|
||||
|
||||
// otherLogin is the login a node alternates to from login.
|
||||
func otherLogin(login string) string {
|
||||
if base, second := strings.CutSuffix(login, SecondLoginSuffix); second {
|
||||
return base
|
||||
}
|
||||
return login + SecondLoginSuffix
|
||||
}
|
||||
|
||||
// loginNode is the node whose login a bus user is: `node.<n>` or `node.<n>--b`.
|
||||
func loginNode(user string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(user, NodeEnginePrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
node := strings.TrimSuffix(rest, SecondLoginSuffix)
|
||||
return node, node != "" && enrolNode(node) == nil
|
||||
}
|
||||
|
||||
// checkAct reads the hand act back by its id and refuses a rotation it does not warrant: none recorded, recorded by
|
||||
// another caller, or older than a day.
|
||||
func (v *Vault) checkAct(caller, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return errors.New("rotate-node carries the hand act that warrants it (`act`): record it through mesh-hand-acts first (novox/hq ADR 0583 §2)")
|
||||
}
|
||||
if v.ReadAct == nil {
|
||||
return errors.New("the vault reads no hand acts, so it answers no rotation (novox/hq ADR 0583 §2)")
|
||||
}
|
||||
act, err := v.ReadAct(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the hand act %s could not be read back from mesh-hand-acts: %w", id, err)
|
||||
}
|
||||
switch {
|
||||
case act == nil:
|
||||
return fmt.Errorf("mesh-hand-acts holds no hand act %s: record one first (novox/hq ADR 0583 §2)", id)
|
||||
case act.By != caller:
|
||||
return fmt.Errorf("the hand act %s was recorded by %s, not by %s who asks: a rotation is warranted by its own caller's act", id, act.By, caller)
|
||||
case v.Now().Sub(act.At) > HandActWithin:
|
||||
return fmt.Errorf("the hand act %s is older than %s: record a new one", id, HandActWithin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rotatingNodes are the nodes whose key says a rotation runs, sorted.
|
||||
func (v *Vault) rotatingNodes() ([]NodeLogin, error) {
|
||||
if v.Logins == nil {
|
||||
return nil, nil
|
||||
}
|
||||
keys, err := v.Logins.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []NodeLogin
|
||||
for _, k := range keys {
|
||||
nl, err := v.Logins.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nl != nil && nl.Rotating != "" {
|
||||
out = append(out, *nl)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RotateNodeArgs are `rotate-node`'s arguments.
|
||||
type RotateNodeArgs struct {
|
||||
Node string
|
||||
Why string
|
||||
Act string
|
||||
}
|
||||
|
||||
// RotateNodeAnswer says a rotation began, or that it waits for the ones before it. Never a value.
|
||||
type RotateNodeAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login,omitempty"`
|
||||
Rotating string `json:"rotating,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Revision uint64 `json:"list_revision,omitempty"`
|
||||
Queued bool `json:"queued,omitempty"`
|
||||
// Behind are the nodes whose rotation it waits for, running or asked before.
|
||||
Behind []string `json:"behind,omitempty"`
|
||||
}
|
||||
|
||||
// RotateNode answers caller's `rotate-node`.
|
||||
func (v *Vault) RotateNode(caller string, a RotateNodeArgs) (RotateNodeAnswer, error) {
|
||||
if err := fromAGrantedCaller(caller, "rotate-node"); err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
}
|
||||
if err := enrolNode(a.Node); err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
}
|
||||
if strings.TrimSpace(a.Why) == "" {
|
||||
return RotateNodeAnswer{}, errors.New("rotate-node says why (`why`)")
|
||||
}
|
||||
if err := v.checkAct(caller, a.Act); err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
}
|
||||
if v.Accounts == nil {
|
||||
return RotateNodeAnswer{}, errors.New("the vault has no state of bus accounts to write to")
|
||||
}
|
||||
v.leaseAfresh()
|
||||
if on, why := v.leaseHanded(); !on {
|
||||
return RotateNodeAnswer{}, fmt.Errorf("no rotation begins: %s", why)
|
||||
}
|
||||
v.minting.mu.Lock()
|
||||
revision := v.minting.loadedRevision
|
||||
v.minting.mu.Unlock()
|
||||
if revision == 0 {
|
||||
return RotateNodeAnswer{}, errors.New("no rotation begins: the revision of the user list the bus loaded is not known yet")
|
||||
}
|
||||
bus, err := v.busNode()
|
||||
if err != nil {
|
||||
return RotateNodeAnswer{}, fmt.Errorf("no rotation is ordered: the node holding the bus is not known (the bus node rotates last): %w", err)
|
||||
}
|
||||
v.nr.mu.Lock()
|
||||
defer v.nr.mu.Unlock()
|
||||
if nl, err := v.getLogin(a.Node); err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
} else if nl != nil && nl.Rotating != "" {
|
||||
return RotateNodeAnswer{}, fmt.Errorf("%s is rotating already, since %s (second login %s)", a.Node, nl.Since.Format(time.RFC3339), nl.Rotating)
|
||||
}
|
||||
for _, q := range v.nr.queue {
|
||||
if q.Node == a.Node {
|
||||
return RotateNodeAnswer{}, fmt.Errorf("%s's rotation is asked already, by %s, and waits its turn", a.Node, q.By)
|
||||
}
|
||||
}
|
||||
running, err := v.rotatingNodes()
|
||||
if err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
}
|
||||
var behind []string
|
||||
for _, r := range running {
|
||||
behind = append(behind, r.Node)
|
||||
}
|
||||
if a.Node == bus {
|
||||
for _, q := range v.nr.queue {
|
||||
behind = append(behind, q.Node)
|
||||
}
|
||||
}
|
||||
if len(behind) > 0 {
|
||||
v.nr.queue = append(v.nr.queue, queuedRotation{Node: a.Node, By: caller, Why: a.Why, Act: a.Act, At: v.Now().UTC()})
|
||||
v.log("the rotation of %s waits for %s (asked by %s): one rotation at a time, the bus node %s last", a.Node,
|
||||
strings.Join(behind, ", "), caller, bus)
|
||||
return RotateNodeAnswer{Node: a.Node, Queued: true, Behind: behind}, nil
|
||||
}
|
||||
nl, err := v.beginNodeRotation(caller, a.Node, a.Why, a.Act, revision)
|
||||
if err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
}
|
||||
return RotateNodeAnswer{Node: a.Node, Login: nl.Login, Rotating: nl.Rotating, Stage: nl.Stage, Revision: revision}, nil
|
||||
}
|
||||
|
||||
// beginNodeRotation makes the other login and records the rotation; under nr.mu.
|
||||
func (v *Vault) beginNodeRotation(by, node, why, act string, revision uint64) (NodeLogin, error) {
|
||||
cur, err := v.currentLogin(node)
|
||||
if err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
curKey, err := v.Accounts.Get(cur)
|
||||
if err != nil {
|
||||
return NodeLogin{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, cur, err)
|
||||
}
|
||||
if curKey == nil {
|
||||
return NodeLogin{}, fmt.Errorf("%s holds no key of %s, the login %s dials as: there is nothing to rotate", BusAccountsState, cur, node)
|
||||
}
|
||||
second := otherLogin(cur)
|
||||
value, password, err := v.accountValue(node, second)
|
||||
if err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
secret := loginSecret(node, second)
|
||||
c, err := v.keep(secret, value, "the vault", "", OriginMade)
|
||||
if err != nil {
|
||||
return NodeLogin{}, fmt.Errorf("the login %s is not kept: %w", second, err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), v.hashCost())
|
||||
if err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
at := v.Now().UTC()
|
||||
fp := Fingerprint(value)
|
||||
if err := v.Accounts.Put(AccountHash{User: second, Hash: string(hash), Of: secret, Generation: c.Generation,
|
||||
Fingerprint: fp, At: at, Kept: KeptNode}); err != nil {
|
||||
return NodeLogin{}, fmt.Errorf("the hash of %s could not be written to %s: %w", second, BusAccountsState, err)
|
||||
}
|
||||
v.saidHashed(second, secret, c.Generation, fp, at)
|
||||
marked := *curKey
|
||||
marked.Rotating = &Rotating{Login: second, Generation: c.Generation, Since: at, Revision: revision}
|
||||
if err := v.Accounts.Put(marked); err != nil {
|
||||
return NodeLogin{}, fmt.Errorf("%s could not be marked rotating: %w", cur, err)
|
||||
}
|
||||
nl := NodeLogin{Node: node, Login: cur, Rotating: second, Stage: StageBegun, Since: at, ListRevision: revision}
|
||||
if err := v.putLogin(nl); err != nil {
|
||||
return NodeLogin{}, fmt.Errorf("%s %s could not be written: %w", NodeLoginsState, node, err)
|
||||
}
|
||||
v.sayRotation(RotationBegunEvent, nl, map[string]any{"why": why, "act": act, "by": by})
|
||||
v.log("the rotation of %s begins at the loaded list's revision %d: %s alternates to %s (asked by %s, hand act %s)", node,
|
||||
revision, cur, second, by, act)
|
||||
return nl, nil
|
||||
}
|
||||
|
||||
// sayRotation says an event of a rotation: the node, its logins, the stage; never a value or a hash.
|
||||
func (v *Vault) sayRotation(event string, nl NodeLogin, extra map[string]any) {
|
||||
if v.Emit == nil {
|
||||
return
|
||||
}
|
||||
body := map[string]any{"node": nl.Node, "login": nl.Login, "rotating": nl.Rotating, "stage": nl.Stage,
|
||||
"at": v.Now().UTC().Format(time.RFC3339Nano)}
|
||||
for k, x := range extra {
|
||||
body[k] = x
|
||||
}
|
||||
if err := v.Emit(event, body); err != nil {
|
||||
v.log("the rotation of %s moved, but %s could not be said: %v", nl.Node, event, err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadedListHeld keeps the users the bus node's loaded list holds, with their hashes (what its handing opened to).
|
||||
func (v *Vault) loadedListHeld(from string, users map[string]string) {
|
||||
v.nr.mu.Lock()
|
||||
v.nr.loaded, v.nr.loadedFrom = users, from
|
||||
v.nr.mu.Unlock()
|
||||
}
|
||||
|
||||
// loadedHolds says the loaded list holds user with exactly hash; loadedLacks that it is known and holds no user.
|
||||
func (v *Vault) loadedHolds(user, hash string) bool {
|
||||
v.nr.mu.Lock()
|
||||
defer v.nr.mu.Unlock()
|
||||
return v.nr.loaded != nil && hash != "" && v.nr.loaded[user] == hash
|
||||
}
|
||||
|
||||
func (v *Vault) loadedLacks(user string) bool {
|
||||
v.nr.mu.Lock()
|
||||
defer v.nr.mu.Unlock()
|
||||
if len(v.nr.loaded) == 0 {
|
||||
return false
|
||||
}
|
||||
_, held := v.nr.loaded[user]
|
||||
return !held
|
||||
}
|
||||
|
||||
// advanceRotations moves every rotation as far as the loaded list lets it: begun to ready when the list holds the new
|
||||
// login, confirmed to done when it lacks the old one. At each adoption of the loaded list and at the minute pass.
|
||||
func (v *Vault) advanceRotations() {
|
||||
running, err := v.rotatingNodes()
|
||||
if err != nil {
|
||||
v.log("the rotations could not be read: %v", err)
|
||||
return
|
||||
}
|
||||
for _, nl := range running {
|
||||
switch nl.Stage {
|
||||
case StageBegun:
|
||||
k, err := v.Accounts.Get(nl.Rotating)
|
||||
if err != nil || k == nil || !v.loadedHolds(nl.Rotating, k.Hash) {
|
||||
continue
|
||||
}
|
||||
nl.Stage = StageReady
|
||||
if err := v.putLogin(nl); err != nil {
|
||||
v.log("%s is ready, and %s could not be written: %v", nl.Rotating, NodeLoginsState, err)
|
||||
continue
|
||||
}
|
||||
v.sayRotation(NodeAccountReadyEvent, nl, nil)
|
||||
v.log("the loaded user list holds %s: %s may dial as it", nl.Rotating, nl.Node)
|
||||
case StageConfirmed:
|
||||
if !v.loadedLacks(nl.Rotating) {
|
||||
continue
|
||||
}
|
||||
v.finishRotation(nl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishRotation ends a confirmed rotation whose old hash the loaded list lacks: the old value dropped, the rotating part
|
||||
// of the key cleared, rotation-done said.
|
||||
func (v *Vault) finishRotation(nl NodeLogin) {
|
||||
old := nl.Rotating
|
||||
if err := v.Store.Delete(loginSecret(nl.Node, old)); err != nil {
|
||||
v.log("the old login %s of %s is out of the list, and its value could not be dropped: %v", old, nl.Node, err)
|
||||
return
|
||||
}
|
||||
done := NodeLogin{Node: nl.Node, Login: nl.Login, Since: v.Now().UTC()}
|
||||
if err := v.putLogin(done); err != nil {
|
||||
v.log("the rotation of %s is done, and %s could not be written: %v", nl.Node, NodeLoginsState, err)
|
||||
return
|
||||
}
|
||||
v.sayRotation(RotationDoneEvent, done, map[string]any{"dropped": old})
|
||||
v.log("the rotation of %s is done: it dials as %s, and %s is gone from the list and the vault", nl.Node, nl.Login, old)
|
||||
}
|
||||
|
||||
// ConfirmAnswer is what `node-account-confirm` answers.
|
||||
type ConfirmAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Stage string `json:"stage"`
|
||||
}
|
||||
|
||||
// NodeAccountConfirm answers `node-account-confirm`: the node-engine, dialled as the new login (which the runtime
|
||||
// proves), says it connects with it. The old login's hash is then removed from bus-accounts; rotation-done follows when
|
||||
// the loaded list lacks it.
|
||||
func (v *Vault) NodeAccountConfirm(caller string) (ConfirmAnswer, error) {
|
||||
if err := fromAGrantedCaller(caller, "node-account-confirm"); err != nil {
|
||||
return ConfirmAnswer{}, err
|
||||
}
|
||||
node, ok := loginNode(caller)
|
||||
if !ok {
|
||||
return ConfirmAnswer{}, fmt.Errorf("%s is no node-engine's login: nothing is retired", caller)
|
||||
}
|
||||
nl, err := v.getLogin(node)
|
||||
if err != nil {
|
||||
return ConfirmAnswer{}, err
|
||||
}
|
||||
if nl == nil || nl.Rotating != caller || (nl.Stage != StageBegun && nl.Stage != StageReady) {
|
||||
return ConfirmAnswer{}, fmt.Errorf("%s is not the new login of a rotation of %s under way: nothing is retired", caller, node)
|
||||
}
|
||||
old := nl.Login
|
||||
if err := v.Accounts.Delete(old); err != nil {
|
||||
return ConfirmAnswer{}, fmt.Errorf("the hash of %s could not be taken out of %s: %w", old, BusAccountsState, err)
|
||||
}
|
||||
confirmed := NodeLogin{Node: node, Login: caller, Rotating: old, Stage: StageConfirmed, Since: v.Now().UTC(), ListRevision: nl.ListRevision}
|
||||
if err := v.putLogin(confirmed); err != nil {
|
||||
return ConfirmAnswer{}, fmt.Errorf("the old hash of %s is out, and %s %s could not be written: %w", node, NodeLoginsState, node, err)
|
||||
}
|
||||
v.log("%s confirmed %s: the hash of %s is removed from %s", node, caller, old, BusAccountsState)
|
||||
v.advanceRotations()
|
||||
return ConfirmAnswer{Node: node, Login: caller, Stage: StageConfirmed}, nil
|
||||
}
|
||||
|
||||
// AbandonAnswer says what `abandon` took away.
|
||||
type AbandonAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login,omitempty"`
|
||||
Dropped string `json:"dropped,omitempty"`
|
||||
Queued bool `json:"was_queued,omitempty"`
|
||||
}
|
||||
|
||||
// AbandonRotation answers `abandon`: the new login and its value are dropped, the old hash kept and the rotating part
|
||||
// cleared; a rotation only asked is taken off the queue. Not after the confirmation, when the old hash is already gone.
|
||||
func (v *Vault) AbandonRotation(caller, node string) (AbandonAnswer, error) {
|
||||
if err := fromAGrantedCaller(caller, "abandon"); err != nil {
|
||||
return AbandonAnswer{}, err
|
||||
}
|
||||
if err := enrolNode(node); err != nil {
|
||||
return AbandonAnswer{}, err
|
||||
}
|
||||
v.nr.mu.Lock()
|
||||
defer v.nr.mu.Unlock()
|
||||
out := AbandonAnswer{Node: node}
|
||||
if i := slices.IndexFunc(v.nr.queue, func(q queuedRotation) bool { return q.Node == node }); i >= 0 {
|
||||
v.nr.queue = slices.Delete(v.nr.queue, i, i+1)
|
||||
out.Queued = true
|
||||
}
|
||||
nl, err := v.getLogin(node)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if nl == nil || nl.Rotating == "" {
|
||||
if out.Queued {
|
||||
return out, nil
|
||||
}
|
||||
return out, fmt.Errorf("%s rotates nothing: nothing was abandoned", node)
|
||||
}
|
||||
if nl.Stage == StageConfirmed {
|
||||
return out, fmt.Errorf("the rotation of %s is confirmed: the old login %s is out of the list, and %s is the login in use; rotate again to change it",
|
||||
node, nl.Rotating, nl.Login)
|
||||
}
|
||||
second := nl.Rotating
|
||||
if err := v.Accounts.Delete(second); err != nil {
|
||||
return out, fmt.Errorf("the hash of %s could not be taken out of %s: %w", second, BusAccountsState, err)
|
||||
}
|
||||
if err := v.Store.Delete(loginSecret(node, second)); err != nil {
|
||||
return out, fmt.Errorf("the value of %s could not be dropped: %w", second, err)
|
||||
}
|
||||
if k, err := v.Accounts.Get(nl.Login); err == nil && k != nil && k.Rotating != nil {
|
||||
cleared := *k
|
||||
cleared.Rotating = nil
|
||||
if err := v.Accounts.Put(cleared); err != nil {
|
||||
return out, fmt.Errorf("%s could not be marked as not rotating: %w", nl.Login, err)
|
||||
}
|
||||
}
|
||||
if err := v.putLogin(NodeLogin{Node: node, Login: nl.Login, Since: v.Now().UTC()}); err != nil {
|
||||
return out, fmt.Errorf("%s %s could not be written: %w", NodeLoginsState, node, err)
|
||||
}
|
||||
_ = v.recordHanding(Handing{To: caller, Name: loginSecret(node, second), Outcome: "dropped", Why: "rotation of " + node + " abandoned"})
|
||||
v.log("the rotation of %s is abandoned by %s: %s and its value are dropped, %s keeps its hash", node, caller, second, nl.Login)
|
||||
out.Login, out.Dropped = nl.Login, second
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RotationPass is the minute pass of rotations: each moves as far as the loaded list lets it, and the next queued one
|
||||
// begins when none runs (the bus node after every other).
|
||||
func (v *Vault) RotationPass() {
|
||||
if v.Logins == nil || v.Accounts == nil {
|
||||
return
|
||||
}
|
||||
v.advanceRotations()
|
||||
running, err := v.rotatingNodes()
|
||||
if err != nil || len(running) > 0 {
|
||||
return
|
||||
}
|
||||
v.leaseAfresh()
|
||||
if on, _ := v.leaseHanded(); !on {
|
||||
return
|
||||
}
|
||||
bus, err := v.busNode()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
v.minting.mu.Lock()
|
||||
revision := v.minting.loadedRevision
|
||||
v.minting.mu.Unlock()
|
||||
if revision == 0 {
|
||||
return
|
||||
}
|
||||
v.nr.mu.Lock()
|
||||
defer v.nr.mu.Unlock()
|
||||
next := slices.IndexFunc(v.nr.queue, func(q queuedRotation) bool { return q.Node != bus })
|
||||
if next < 0 {
|
||||
next = slices.IndexFunc(v.nr.queue, func(q queuedRotation) bool { return q.Node == bus })
|
||||
}
|
||||
if next < 0 {
|
||||
return
|
||||
}
|
||||
q := v.nr.queue[next]
|
||||
v.nr.queue = slices.Delete(v.nr.queue, next, next+1)
|
||||
if _, err := v.beginNodeRotation(q.By, q.Node, q.Why, q.Act, revision); err != nil {
|
||||
v.log("the queued rotation of %s did not begin: %v", q.Node, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RotationState is what vault_status says of rotations: the keys of node-logins that rotate, and the queue.
|
||||
type RotationState struct {
|
||||
Rotating []NodeLogin `json:"rotating"`
|
||||
Queued []queuedRotation `json:"queued"`
|
||||
}
|
||||
|
||||
// RotationState answers how the rotations stand.
|
||||
func (v *Vault) RotationState() RotationState {
|
||||
running, _ := v.rotatingNodes()
|
||||
v.nr.mu.Lock()
|
||||
defer v.nr.mu.Unlock()
|
||||
return RotationState{Rotating: append([]NodeLogin{}, running...), Queued: append([]queuedRotation{}, v.nr.queue...)}
|
||||
}
|
||||
|
||||
// LoginsPass deletes the key of every node that left the placement (ADR 0583 §1 insight). A placement that cannot be
|
||||
// read, or holds no node, deletes nothing.
|
||||
func (v *Vault) LoginsPass() {
|
||||
if v.Logins == nil || v.Placement.Keys == nil {
|
||||
return
|
||||
}
|
||||
placed, err := v.placements()
|
||||
if err != nil || len(placed) == 0 {
|
||||
return
|
||||
}
|
||||
keys, err := v.Logins.Keys()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, in := placed[k]; !in {
|
||||
v.nodeLeft(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NodeLeft is heard when a node's key of the placement is deleted: its key of node-logins goes with it.
|
||||
func (v *Vault) NodeLeft(node string) {
|
||||
if v.Logins == nil || v.Placement.Read == nil {
|
||||
return
|
||||
}
|
||||
if _, _, found, err := v.nodePlaced(node); err != nil || found {
|
||||
return
|
||||
}
|
||||
v.nodeLeft(node)
|
||||
}
|
||||
|
||||
func (v *Vault) nodeLeft(node string) {
|
||||
if err := v.Logins.Delete(node); err != nil {
|
||||
v.log("%s left the placement, and its key of %s could not be deleted: %v", node, NodeLoginsState, err)
|
||||
return
|
||||
}
|
||||
v.log("%s left the placement: its key of %s is deleted", node, NodeLoginsState)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func (v *Vault) goneAccounts() ([]string, int, error) {
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if a == nil || a.Adopted || a.Of == "" {
|
||||
if a == nil || a.Adopted || a.Of == "" || a.Kept != "" {
|
||||
continue
|
||||
}
|
||||
held++
|
||||
@@ -207,6 +207,9 @@ func (v *Vault) retireOne(user, why, by string) error {
|
||||
if a == nil || a.Adopted || a.Of == "" {
|
||||
return fmt.Errorf("the vault holds no account of %s it made or was handed: an adopted hash is never retired here", user)
|
||||
}
|
||||
if a.Kept != "" {
|
||||
return fmt.Errorf("%s is kept for an enrolment or a node's login, which ends with its own verb (enrolment-token, node-account, node-account-confirm, abandon): it is never retired as unwanted", user)
|
||||
}
|
||||
rec, held, err := v.Store.Read(a.Of)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The vault whole against a real bus, rotating a node's login with a stand-in engine (novox/hq ADR 0583 §1 to §7, issue
|
||||
// 774): the built binary played over its stdin and stdout as the node's tool runtime plays it, as in the mint test. The
|
||||
// test is the engine and the bus node: it states its sealing key, takes the new login through `own`, and "reloads the
|
||||
// list" by handing the bus-accounts hashes in the bus module's have, as the bus node's node-engine does. At every step at
|
||||
// least one of the engine's logins authenticates against the list loaded last, and with the list never reloaded the vault
|
||||
// never says node-account-ready.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
)
|
||||
|
||||
// 774/S50/AC1.
|
||||
func TestTheVaultWholeRotatesANodesLoginWithAStandInEngine(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "mesh-vault")
|
||||
build := exec.Command("go", "build", "-o", bin, ".")
|
||||
build.Stderr = os.Stderr
|
||||
if err := build.Run(); err != nil {
|
||||
t.Fatalf("the vault does not build: %v", err)
|
||||
}
|
||||
nc, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range []string{"mesh-vault_bus-accounts", "mesh-vault_node-logins", "mesh-grants_accounts", "mesh-controller_lease",
|
||||
"mesh-provisioner_plans", "node-engine_have", "mesh-delivery-assigner_placement"} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
opPub, _, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := filepath.Join(dir, "root")
|
||||
_ = os.MkdirAll(root, 0o700)
|
||||
export, _ := json.Marshal(map[string]string{"operator-key": base64.StdEncoding.EncodeToString(opPub[:])})
|
||||
if err := os.WriteFile(filepath.Join(root, "export.json"), export, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin)
|
||||
cmd.Env = append(os.Environ(), "MESH_VAULT_STORE="+filepath.Join(dir, "store"), "MESH_VAULT_STATE="+filepath.Join(dir, "state"),
|
||||
"MESH_VAULT_ROOT="+root, "MESH_VAULT_LEDGER="+filepath.Join(dir, "ledger"), "MESH_RECEIVES="+filepath.Join(dir, "mesh.json"),
|
||||
"MESH_NODE=novox", "MESH_MODULE=mesh-vault")
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = stdin.Close(); cancel(); _ = cmd.Wait() })
|
||||
p := &player{t: t, ctx: ctx, nc: nc, js: js, in: stdin, pending: map[string]chan wire{}}
|
||||
// mesh-hand-acts answers the one hand act the test recorded.
|
||||
p.ask = func(key string, _ json.RawMessage) (any, error) {
|
||||
if key != "mesh-hand-acts.hand-acts" {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
return map[string]any{"acts": []HandActSeen{{ID: "act-1", By: "person.jochen", At: time.Now()}}}, nil
|
||||
}
|
||||
go p.serve(stdout)
|
||||
|
||||
// The bus node (novox, running nats) and the node that rotates (g14); the lease handed away; g14's login adopted from
|
||||
// the loaded list, its password known to the engine.
|
||||
for node, modules := range map[string][]string{"novox": {"nats"}, "g14": {"web"}} {
|
||||
pl := delivery.Placement{V: delivery.Version, Node: node}
|
||||
for _, m := range modules {
|
||||
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
values, err := delivery.PlacementValues(pl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, raw := range values {
|
||||
kv, _ := p.kv("mesh-delivery-assigner.placement")
|
||||
if _, err := kv.Put(ctx, k, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
|
||||
oldPassword := "the-old-password-of-g14"
|
||||
oldHash, _ := bcrypt.GenerateFromPassword([]byte(oldPassword), bcrypt.MinCost)
|
||||
p.put(t, "mesh-vault.bus-accounts", "node.g14", AccountHash{User: "node.g14", Hash: string(oldHash), Adopted: true})
|
||||
// The vault's public key, which the bus node's engine seals its handing to.
|
||||
var vaultKey seal.KeyPair
|
||||
eventually(t, "the vault's key", func() bool {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "state", OwnKeyFile))
|
||||
return err == nil && json.Unmarshal(raw, &vaultKey) == nil && vaultKey.PublicKey != ""
|
||||
})
|
||||
|
||||
// The bus node reloads the list from bus-accounts and hands the hashes of what it loaded in its have; the engine's logins
|
||||
// are its passwords. An authentication is a bcrypt compare against the list loaded last.
|
||||
passwords := map[string]string{"node.g14": oldPassword}
|
||||
var loaded map[string]string
|
||||
revision := uint64(0)
|
||||
reload := func() {
|
||||
kv, _ := p.kv("mesh-vault.bus-accounts")
|
||||
keys, err := kv.Keys(ctx)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var accounts []delivery.HandedAccount
|
||||
loaded = map[string]string{}
|
||||
for _, k := range keys {
|
||||
a := p.hashOf(t, k)
|
||||
if a == nil || !delivery.IsBcrypt(a.Hash) {
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, delivery.HandedAccount{User: k, Hash: a.Hash})
|
||||
loaded[k] = a.Hash
|
||||
}
|
||||
revision++
|
||||
list, _ := json.Marshal(accounts)
|
||||
handed, err := delivery.HandAccounts(accounts, vaultKey.PublicKey, Fingerprint(string(list)), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := delivery.EncodeHave(delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
|
||||
UserList: &delivery.UserListWritten{Source: "k", Revision: revision, Users: len(accounts), Handed: &handed}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hk, _ := p.kv("node-engine.have")
|
||||
if _, err := hk.Put(ctx, "novox.nats", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
authenticates := func(step string) {
|
||||
t.Helper()
|
||||
for user, password := range passwords {
|
||||
if h, ok := loaded[user]; ok && bcrypt.CompareHashAndPassword([]byte(h), []byte(password)) == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s: none of the engine's logins %v authenticates against the loaded list %v", step, passwords, loaded)
|
||||
}
|
||||
said := func(event string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return strings.Contains(strings.Join(p.said, "\n"), event+" ")
|
||||
}
|
||||
reload()
|
||||
authenticates("before the rotation")
|
||||
|
||||
enginePub, enginePriv := aSealingKey(t)
|
||||
if _, err := p.call(Seat+".node-key", "node.g14", map[string]any{"key": enginePub}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The rotation begins once the vault has heard the loaded list's revision.
|
||||
var begun RotateNodeAnswer
|
||||
eventually(t, "the rotation begins", func() bool {
|
||||
raw, err := p.call(Seat+".rotate-node", "person.jochen", map[string]any{"node": "g14", "why": "the weekly rotation", "act": "act-1"})
|
||||
return err == nil && json.Unmarshal(raw, &begun) == nil && begun.Rotating == "node.g14--b"
|
||||
})
|
||||
authenticates("rotation begun")
|
||||
// The list is never reloaded: node-account-ready is never said.
|
||||
time.Sleep(time.Second)
|
||||
if said(NodeAccountReadyEvent) {
|
||||
t.Fatal("node-account-ready was said while the loaded list lacks the new login")
|
||||
}
|
||||
// The engine takes the new login through own, sealed to its key.
|
||||
raw, err := p.call(Seat+".own", "node.g14", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var own OwnAnswer
|
||||
if err := json.Unmarshal(raw, &own); err != nil || len(own.Own) != 1 || own.Own[0].Module != MeshHostModule || own.Own[0].Name != "node.g14--b" {
|
||||
t.Fatalf("own answers %+v, %v", own.Own, err)
|
||||
}
|
||||
value, err := seal.Open(own.Own[0].Sealed, enginePriv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var account struct{ User, Password string }
|
||||
if err := json.Unmarshal([]byte(value), &account); err != nil || account.User != "node.g14--b" {
|
||||
t.Fatalf("the new login is %s, %v", value, err)
|
||||
}
|
||||
passwords["node.g14--b"] = account.Password
|
||||
// The bus node reloads the list: the new login is in it, both authenticate, node-account-ready is said.
|
||||
reload()
|
||||
eventually(t, "node-account-ready", func() bool { return said(NodeAccountReadyEvent) })
|
||||
authenticates("the list holds both logins")
|
||||
if bcrypt.CompareHashAndPassword([]byte(loaded["node.g14--b"]), []byte(account.Password)) != nil {
|
||||
t.Fatal("the loaded hash of the new login is not of the value the engine took")
|
||||
}
|
||||
// The engine, dialled as the new login, confirms; the vault removes the old hash, and the next reload drops it.
|
||||
if _, err := p.call(Seat+".node-account-confirm", "node.g14--b", map[string]any{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := p.hashOf(t, "node.g14"); h != nil {
|
||||
t.Fatalf("the old login's hash remains after the confirm: %+v", h)
|
||||
}
|
||||
authenticates("the confirm, before the list reloads")
|
||||
reload()
|
||||
authenticates("the list without the old login")
|
||||
eventually(t, "rotation-done", func() bool { return said(RotationDoneEvent) })
|
||||
kv, _ := p.kv("mesh-vault.node-logins")
|
||||
e, err := kv.Get(ctx, "g14")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var nl NodeLogin
|
||||
_ = json.Unmarshal(e.Value(), &nl)
|
||||
if nl.Login != "node.g14--b" || nl.Rotating != "" || nl.Stage != "" {
|
||||
t.Errorf("node-logins g14 is %+v", nl)
|
||||
}
|
||||
if h := p.hashOf(t, "node.g14--b"); h == nil || h.Rotating != nil {
|
||||
t.Errorf("the new login's key is %+v", h)
|
||||
}
|
||||
}
|
||||
@@ -251,16 +251,71 @@ func (v *Vault) Sign(caller string, a SignArgs) (delivery.SignAnswer, error) {
|
||||
}
|
||||
switch a.Kind {
|
||||
case delivery.KindNodeCertificate:
|
||||
if v.fromEnrolment(caller, "sign") == nil {
|
||||
return v.signRedeemingCertificate(caller, a.Node)
|
||||
}
|
||||
return v.signNodeCertificate(caller, a.Node)
|
||||
case delivery.KindEnrolment:
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s may not ask for an enrolment's signature: no caller is named for it until "+
|
||||
"adoption's record names the seat that adopts (novox/hq ADR 0561 §5)", caller)
|
||||
if err := v.fromEnrolment(caller, "sign an enrolment"); err != nil {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s may not ask for an enrolment's signature: only the seat %s, which "+
|
||||
"decides who joins, is named for it (novox/hq ADR 0561 §5, ADR 0587 §6)", caller, EnrolmentModule)
|
||||
}
|
||||
return v.signEnrolment(caller, a.Node, a.Reply)
|
||||
case delivery.KindAuthority:
|
||||
return delivery.SignAnswer{}, fmt.Errorf("the authority is the vault's own, signed for itself; %s may not ask it", caller)
|
||||
}
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%q is not a kind the vault signs (%s)", a.Kind, strings.Join(delivery.SignKinds, ", "))
|
||||
}
|
||||
|
||||
// redeeming says node has an enrolment token held: a machine joining, which the seat mesh-enrolment redeems.
|
||||
func (v *Vault) redeeming(node string) error {
|
||||
if err := enrolNode(node); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, held, err := v.Store.Read(enrolSecret(node)); err != nil {
|
||||
return err
|
||||
} else if !held {
|
||||
return fmt.Errorf("%s holds no enrolment token: it is not a machine joining, so nothing is signed for it", node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// signRedeemingCertificate signs the certificate of a machine that is joining, for the seat mesh-enrolment at the redeem
|
||||
// (ADR 0587 §6): from the same bus state as a node's own, never from bytes the caller chose.
|
||||
func (v *Vault) signRedeemingCertificate(caller, node string) (delivery.SignAnswer, error) {
|
||||
if err := v.redeeming(node); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
return v.signCertificateFor(caller, node)
|
||||
}
|
||||
|
||||
// signEnrolment signs the bytes of an enrolment reply under the enrolment context, for a node that is joining; the
|
||||
// signature is the vault's key's, and the answer carries it with the signer's fingerprint.
|
||||
func (v *Vault) signEnrolment(caller, node, reply string) (delivery.SignAnswer, error) {
|
||||
if err := v.redeeming(node); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
msg, err := base64.StdEncoding.DecodeString(reply)
|
||||
if err != nil || len(msg) == 0 {
|
||||
return delivery.SignAnswer{}, errors.New("an enrolment's signature is asked for the reply's bytes in base64 (`reply`)")
|
||||
}
|
||||
context, err := delivery.SignContext(delivery.KindEnrolment)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
k, err := v.keys()
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
answer := delivery.SignAnswer{Kind: delivery.KindEnrolment, Node: node,
|
||||
Signature: base64.StdEncoding.EncodeToString(delivery.SignMessage(k.key, context, msg)),
|
||||
Signer: delivery.SignerFingerprint(k.key.Public().(ed25519.PublicKey)), Digest: delivery.DigestOf(msg)}
|
||||
if err := v.saySigned(caller, SigningKeySecret, answer); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// signNodeCertificate signs the certificate of the calling machine's internal name for the serving key its record states.
|
||||
func (v *Vault) signNodeCertificate(caller, asked string) (delivery.SignAnswer, error) {
|
||||
node, ok := nodeEngineOf(caller)
|
||||
@@ -271,6 +326,11 @@ func (v *Vault) signNodeCertificate(caller, asked string) (delivery.SignAnswer,
|
||||
if asked != "" && asked != node {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s asks for the certificate of %s, and is %s's node-engine: a machine asks for its own alone", caller, asked, node)
|
||||
}
|
||||
return v.signCertificateFor(caller, node)
|
||||
}
|
||||
|
||||
// signCertificateFor signs node's certificate from its placement's name and its record's serving key.
|
||||
func (v *Vault) signCertificateFor(caller, node string) (delivery.SignAnswer, error) {
|
||||
name, err := v.internalName(node)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
|
||||
@@ -157,6 +157,19 @@ func (s *Store) Write(r Record) error {
|
||||
return writeWhole(s.pathOf(r.Name), append(raw, '\n'))
|
||||
}
|
||||
|
||||
// Delete takes one secret out of the store: for the enrolment token and a joining node's account, which end with
|
||||
// their enrolment (novox/hq ADR 0587 §5), never for a value the mesh keeps. Nothing held is no error.
|
||||
func (s *Store) Delete(name string) error {
|
||||
if err := validName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
path := s.pathOf(name)
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return syncDir(filepath.Dir(path))
|
||||
}
|
||||
|
||||
// Empty says whether the store holds no secret at all.
|
||||
func (s *Store) Empty() (bool, error) {
|
||||
all, err := s.List()
|
||||
|
||||
@@ -75,7 +75,7 @@ func str(description string) map[string]any {
|
||||
}
|
||||
|
||||
func definitions(env *Env) []toolDef {
|
||||
return []toolDef{
|
||||
return append(enrolmentTools(env), []toolDef{
|
||||
{Name: Seat + ".get",
|
||||
Description: "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its " +
|
||||
"generation; answered only to an asker the vault allows, as the runtime proved it (ADR 0349 §11). " +
|
||||
@@ -395,8 +395,13 @@ func definitions(env *Env) []toolDef {
|
||||
"granted, how many times it has been rotated and when, and the fingerprint of the current value. " +
|
||||
"Fingerprints only — the value is never returned.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
enrolments := []EnrolmentHeld{}
|
||||
if env.Vault != nil {
|
||||
enrolments, _ = env.Vault.EnrolmentsHeld()
|
||||
}
|
||||
if env.Ledger == nil {
|
||||
return map[string]any{"holders": []any{}, "count": 0, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil
|
||||
return map[string]any{"holders": []any{}, "count": 0, "enrolments": enrolments,
|
||||
"error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil
|
||||
}
|
||||
var asked []Contribution
|
||||
if env.Receives != "" {
|
||||
@@ -415,7 +420,7 @@ func definitions(env *Env) []toolDef {
|
||||
}
|
||||
holders = append(holders, row)
|
||||
}
|
||||
return map[string]any{"holders": holders, "count": len(holders)}, nil
|
||||
return map[string]any{"holders": holders, "count": len(holders), "enrolments": enrolments}, nil
|
||||
}},
|
||||
{Name: "secret_verify",
|
||||
Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " +
|
||||
@@ -647,9 +652,9 @@ func definitions(env *Env) []toolDef {
|
||||
key, _ := env.Vault.Store.OperatorKey()
|
||||
return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left,
|
||||
"operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(),
|
||||
"minting": env.Vault.MintState(), "retiring": env.Vault.RetireState(), "signing": env.Vault.SigningState(), "ok": left == 0 && env.keyRefused() == "" && key != ""}, nil
|
||||
"minting": env.Vault.MintState(), "retiring": env.Vault.RetireState(), "signing": env.Vault.SigningState(), "ok": left == 0 && env.keyRefused() == "" && key != "", "rotation": env.Vault.RotationState()}, nil
|
||||
}},
|
||||
}
|
||||
}...)
|
||||
}
|
||||
|
||||
// actInput is the arguments of an act on retirement, as the SDK's harness takes them.
|
||||
|
||||
@@ -76,6 +76,17 @@ type Vault struct {
|
||||
// Signers is the vault's state `signers`, where it publishes its signing halves (signing.go, ADR 0550 §2): nil writes
|
||||
// none.
|
||||
Signers SignersStore
|
||||
// EnrolCallers are the callers of the enrolment verbs `enrolment-token` and `node-account` and of `sign` for an
|
||||
// enrolment: the seat mesh-enrolment (enrolment.go). Nil: that module alone.
|
||||
EnrolCallers stdio.Callers
|
||||
// TokenBase says where the bus is and its certificate's fingerprint, for the token shown (enrolment.go): nil shows none.
|
||||
TokenBase func() (TokenBase, error)
|
||||
// Logins is the vault's state node-logins (noderotation.go): nil writes none.
|
||||
Logins NodeLoginStore
|
||||
// ReadAct reads a hand act back from mesh-hand-acts by its id, which a rotation carries (noderotation.go): nil answers none.
|
||||
ReadAct ActReader
|
||||
// nr is what the vault keeps in memory of its node rotations (noderotation.go).
|
||||
nr nodeRotation
|
||||
// signingWhy is why the last signing pass did not finish, under mu (signing.go).
|
||||
signingWhy string
|
||||
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
|
||||
@@ -341,14 +352,30 @@ func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
|
||||
if signingSecret(name) {
|
||||
return Answer{}, fmt.Errorf("%s is the vault's own signing secret and is handed to nobody: ask the verb sign", name)
|
||||
}
|
||||
// A value issued for one holder is that holder's alone, whatever else would allow it (issue 749).
|
||||
if why := notTheHolder(caller, name); why != "" {
|
||||
// An enrolment token is shown to the operator sealed to the operator key, and handed to nobody (ADR 0587 §8).
|
||||
if enrolSecretName(name) {
|
||||
return Answer{}, fmt.Errorf("%s is an enrolment token and is handed to nobody: the operator opens it sealed to the operator key", name)
|
||||
}
|
||||
// A node's login is its node-engine's alone, sealed to the key that machine stated (ADR 0583 §3 insight).
|
||||
isLogin, why := v.loginRefusal(caller, name)
|
||||
if isLogin {
|
||||
if why == "" {
|
||||
why = v.loginKeyRefusal(caller, publicKey)
|
||||
}
|
||||
if why != "" {
|
||||
v.log("refused %s to %s: %s", name, caller, why)
|
||||
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
|
||||
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
|
||||
}
|
||||
} else if why := notTheHolder(caller, name); why != "" {
|
||||
v.log("refused %s to %s: %s", name, caller, why)
|
||||
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
|
||||
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
|
||||
}
|
||||
// The core's own credentials, to the controller alone, sealed only to their machine's key (ADR 0349 §16).
|
||||
if caller == ControllerCaller {
|
||||
if isLogin {
|
||||
// Judged above: the node's own engine, sealed to its own key.
|
||||
} else if caller == ControllerCaller {
|
||||
if why := v.coreRefusal(name, publicKey); why != "" {
|
||||
v.log("refused %s to %s: %s", name, caller, why)
|
||||
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
|
||||
|
||||
@@ -146,10 +146,10 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound"]` {
|
||||
if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins"]` {
|
||||
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+
|
||||
"buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+
|
||||
"credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6)", s)
|
||||
"credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6) and each node's login in use (ADR 0583)", s)
|
||||
}
|
||||
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["node-engine.have","mesh-grants.accounts","mesh-controller.lease","mesh-delivery-assigner.placement","mesh-provisioner.plans","mesh-provisioner.provisions"]` {
|
||||
t.Errorf("the manifest reads %s: the vault reads the seat mesh-provisioner's plans and provisions, the haves, the "+
|
||||
@@ -173,6 +173,13 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Delete(", "", 1))
|
||||
}
|
||||
// Each node's login in use and its rotation's stage, in noderotation.go alone: one read, one write, its keys and a
|
||||
// delete of its own bucket (ADR 0583 §1 insight); never a secret.
|
||||
if f == "noderotation.go" {
|
||||
for _, call := range []string{"Get(", "Put(", "Delete(", "Keys("} {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(NodeLoginsState)."+call, "", -1))
|
||||
}
|
||||
}
|
||||
// The signing keys' public halves, in signing.go alone: one read and one write of its own bucket (ADR 0550 §2).
|
||||
if f == "signing.go" {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(delivery.SignersState).Get(", "", 1))
|
||||
@@ -194,6 +201,8 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
// And the seat's placement, where modules stand (placed.go): its keys and one key, read.
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Keys(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Get(", "", 1))
|
||||
// And one watch of its nodes' keys, for a node that leaves: its node-logins key goes with it (noderotation.go).
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Watch(", "", 1))
|
||||
// And its outcome entries (novox/hq ADR 0570 clauses 3, 6): a value's name and generation, never a value; and
|
||||
// one read of the seat's provisions, which hold none.
|
||||
for _, call := range []string{"Get(", "Put(", "Delete(", "Keys("} {
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
"account-hashed",
|
||||
"own-replaced",
|
||||
"account-issued",
|
||||
"signed"
|
||||
"signed",
|
||||
"rotation-begun",
|
||||
"node-account-ready",
|
||||
"rotation-done"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-hand-acts.hand-acts"
|
||||
],
|
||||
"reads": [
|
||||
"node-engine.have",
|
||||
@@ -37,7 +43,8 @@
|
||||
"history": 2
|
||||
},
|
||||
"signers",
|
||||
"bound"
|
||||
"bound",
|
||||
"node-logins"
|
||||
],
|
||||
"receives": {
|
||||
"secret": "${dir:grants}/mesh.json"
|
||||
@@ -156,7 +163,8 @@
|
||||
"MESH_VAULT_ROOT": "${dir:root}",
|
||||
"MESH_VAULT_STORE": "${dir:store}",
|
||||
"MESH_VAULT_STATE": "${dir:state}",
|
||||
"MESH_VAULT_SECRET_ASK_CALLERS": "node-tools"
|
||||
"MESH_VAULT_SECRET_ASK_CALLERS": "node-tools",
|
||||
"MESH_VAULT_ENROLMENT_CALLERS": "mesh-enrolment"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -182,7 +190,8 @@
|
||||
"own_take_switch",
|
||||
"vault_signers",
|
||||
"vault_signed",
|
||||
"signers_reassert"
|
||||
"signers_reassert",
|
||||
"node_logins"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
@@ -199,7 +208,12 @@
|
||||
"move-asks",
|
||||
"own",
|
||||
"issue-account",
|
||||
"sign"
|
||||
"sign",
|
||||
"enrolment-token",
|
||||
"node-account",
|
||||
"rotate-node",
|
||||
"node-account-confirm",
|
||||
"abandon"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user