Vault: answer the enrolment verbs in mesh-sdk's wire shapes, certify the serving key presented, record each rotation's hand act
Review round 1 found that redeem could never succeed: the vault's answers were not the shapes mesh-enrolment decodes. - enrolment-token reads `sealed` (not `show`), so asking for the sealed token no longer mints a new one; it answers delivery.EnrolmentTokenAnswer: at issue the bus address, its certificate fingerprint and the vault's signing key. - Issue takes the machine's first tunnel (`tunnel`), keeps it beside the token and refuses a token whose Missing() is not empty, so the sealed token is one a machine can use (ADR 0587 §3, insight 2026-11-02). - node-account answers delivery.NodeAccountAnswer: the login, and the password sealed anonymously to the presented raw X25519 key in base64, as the node-engine's SealingKey.Open reads it. - A joining node's certificate is signed for the serving key presented at the redeem, only when the four presented keys have the digest node-account recorded; the name comes from placement (ADR 0587 §6). - rotate-node refuses a hand act that is not rotate-node of this node, keeps the act id in the node's node-logins key (`acts`, beside mesh-sdk's fields) and refuses an id used before (ADR 0583 §2). The enrolment test now calls the real tool handlers and decodes every answer into mesh-sdk's types, failing on an empty field; it replaces the earlier enrolment test, so mesh-vault stays at 134 unit tests. (issue 774, 774/S43, 774/S44, 774/S45, 774/S46, review round 1) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -12,10 +12,13 @@ package main
|
||||
// seat mesh-enrolment may call either: the runtime proves who asks.
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -25,7 +28,6 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -128,34 +130,31 @@ func (v *Vault) fromEnrolment(caller, verb string) error {
|
||||
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.
|
||||
// TokenBase is what a token states of the bus beside its secret: where the bus is and the certificate to expect there.
|
||||
// The machine's first tunnel comes with the issue call (ADR 0587 §3, insight 2026-11-02).
|
||||
type TokenBase struct {
|
||||
Broker string `json:"broker"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Tunnel *delivery.TokenTunnel `json:"tunnel,omitempty"`
|
||||
Broker string `json:"broker"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// EnrolmentTokenArgs are `enrolment-token`'s arguments (the seat's contract, mesh-sdk delivery): the node, when it expires
|
||||
// (the seat's word), the machine's first tunnel at issue, and which of the three acts: issue (neither switch), sealed or
|
||||
// drop.
|
||||
type EnrolmentTokenArgs struct {
|
||||
Node string
|
||||
Expires string
|
||||
Show bool
|
||||
Tunnel *delivery.TokenTunnel
|
||||
Sealed 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"`
|
||||
}
|
||||
// EnrolmentTokenAnswer is mesh-sdk's delivery.EnrolmentTokenAnswer, the wire shape mesh-enrolment decodes: at issue the
|
||||
// bus's address, its certificate's fingerprint and the vault's public signing key; when sealed, the whole token sealed to
|
||||
// the operator key; at drop, whether it was dropped. Never a token in the clear.
|
||||
type EnrolmentTokenAnswer = delivery.EnrolmentTokenAnswer
|
||||
|
||||
// enrolTunnel is the vault's name of the first tunnel a node's token carries, kept beside the token.
|
||||
func enrolTunnel(node string) string { return "enrol/" + node + "/tunnel" }
|
||||
|
||||
// EnrolmentToken answers caller's `enrolment-token`.
|
||||
func (v *Vault) EnrolmentToken(caller string, a EnrolmentTokenArgs) (EnrolmentTokenAnswer, error) {
|
||||
@@ -169,11 +168,11 @@ func (v *Vault) EnrolmentToken(caller string, a EnrolmentTokenArgs) (EnrolmentTo
|
||||
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 && a.Sealed:
|
||||
return EnrolmentTokenAnswer{}, errors.New("a token is answered sealed or dropped, not both")
|
||||
case a.Drop:
|
||||
return v.dropEnrolmentToken(caller, a.Node)
|
||||
case a.Show:
|
||||
case a.Sealed:
|
||||
return v.showEnrolmentToken(caller, a.Node)
|
||||
}
|
||||
return v.issueEnrolmentToken(caller, a)
|
||||
@@ -182,6 +181,29 @@ func (v *Vault) EnrolmentToken(caller string, a EnrolmentTokenArgs) (EnrolmentTo
|
||||
// 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) {
|
||||
// What the machine must trust is known before anything is kept: the bus, its certificate, the vault's signing key,
|
||||
// and the first tunnel whole (ADR 0587 §3), so the token shown later is one a machine can use.
|
||||
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)
|
||||
}
|
||||
signer := keys.key.Public().(ed25519.PublicKey)
|
||||
if a.Tunnel == nil {
|
||||
return EnrolmentTokenAnswer{}, errors.New("enrolment-token at issue carries the machine's first tunnel (`tunnel`: key, address, range, " +
|
||||
"hub_key, hub_endpoint), so the machine reaches the bus over the private network (novox/hq ADR 0587 §3)")
|
||||
}
|
||||
if missing := (delivery.Token{Version: 1, Broker: base.Broker, Fingerprint: base.Fingerprint, Signer: signer,
|
||||
Secret: "-", Tunnel: a.Tunnel}).Missing(); len(missing) > 0 {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the token of %s would lack %s: nothing was issued", a.Node, strings.Join(missing, ", "))
|
||||
}
|
||||
tunnel, err := json.Marshal(a.Tunnel)
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
@@ -196,6 +218,9 @@ func (v *Vault) issueEnrolmentToken(caller string, a EnrolmentTokenArgs) (Enrolm
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
if _, err := v.keepWith(enrolTunnel(a.Node), string(tunnel), "the vault", caller, OriginMade, keepOptions{quiet: true}); err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the first tunnel of %s is not kept: %w", a.Node, 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,
|
||||
@@ -205,7 +230,8 @@ func (v *Vault) issueEnrolmentToken(caller string, a EnrolmentTokenArgs) (Enrolm
|
||||
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
|
||||
return EnrolmentTokenAnswer{Node: a.Node, Broker: base.Broker, Fingerprint: base.Fingerprint,
|
||||
Signer: base64.StdEncoding.EncodeToString(signer)}, nil
|
||||
}
|
||||
|
||||
// showEnrolmentToken answers the whole token sealed to the operator key: the secret held, the bus's address and
|
||||
@@ -237,8 +263,26 @@ func (v *Vault) showEnrolmentToken(caller, node string) (EnrolmentTokenAnswer, e
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the token carries the vault's signing key, which is not held yet: %w", err)
|
||||
}
|
||||
text, err := delivery.Token{Version: 1, Node: node, Broker: base.Broker, Fingerprint: base.Fingerprint,
|
||||
Signer: []byte(keys.key.Public().(ed25519.PublicKey)), Secret: secret, Tunnel: base.Tunnel}.Encode()
|
||||
var tunnel *delivery.TokenTunnel
|
||||
if trec, theld, err := v.Store.Read(enrolTunnel(node)); err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
} else if theld {
|
||||
raw, err := openAtRest(trec, v)
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the first tunnel of %s does not open with the vault's key: %w", node, err)
|
||||
}
|
||||
tunnel = &delivery.TokenTunnel{}
|
||||
if err := json.Unmarshal([]byte(raw), tunnel); err != nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the first tunnel of %s is not readable: %w", node, err)
|
||||
}
|
||||
}
|
||||
signer := keys.key.Public().(ed25519.PublicKey)
|
||||
tok := delivery.Token{Version: 1, Node: node, Broker: base.Broker, Fingerprint: base.Fingerprint,
|
||||
Signer: []byte(signer), Secret: secret, Tunnel: tunnel}
|
||||
if tunnel == nil {
|
||||
return EnrolmentTokenAnswer{}, fmt.Errorf("the vault keeps no first tunnel for %s's token: issue it again with its tunnel", node)
|
||||
}
|
||||
text, err := tok.Encode()
|
||||
if err != nil {
|
||||
return EnrolmentTokenAnswer{}, err
|
||||
}
|
||||
@@ -248,7 +292,8 @@ func (v *Vault) showEnrolmentToken(caller, node string) (EnrolmentTokenAnswer, e
|
||||
}
|
||||
_ = 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
|
||||
return EnrolmentTokenAnswer{Node: node, Broker: base.Broker, Fingerprint: base.Fingerprint,
|
||||
Signer: base64.StdEncoding.EncodeToString(signer), Sealed: sealed}, nil
|
||||
}
|
||||
|
||||
// tokenBase is what a token states of the bus, or why the vault knows none.
|
||||
@@ -263,7 +308,7 @@ func (v *Vault) tokenBase() (TokenBase, error) {
|
||||
// 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}
|
||||
out := EnrolmentTokenAnswer{Node: node}
|
||||
a, err := v.Accounts.Get(user)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -285,6 +330,13 @@ func (v *Vault) dropEnrolmentToken(caller, node string) (EnrolmentTokenAnswer, e
|
||||
}
|
||||
out.Dropped = true
|
||||
}
|
||||
if _, held, err := v.Store.Read(enrolTunnel(node)); err != nil {
|
||||
return out, err
|
||||
} else if held {
|
||||
if err := v.Store.Delete(enrolTunnel(node)); err != nil {
|
||||
return out, fmt.Errorf("the first tunnel of %s could not be deleted: %w", node, err)
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -312,15 +364,10 @@ type NodeAccountArgs struct {
|
||||
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"`
|
||||
}
|
||||
// NodeAccountAnswer is mesh-sdk's delivery.NodeAccountAnswer, the wire shape mesh-enrolment decodes and relays into the
|
||||
// redeem answer: the login, and the account sealed to the presented key, as the node-engine opens it (the password,
|
||||
// sealed anonymously to its raw X25519 key, in base64); or that it was retired.
|
||||
type NodeAccountAnswer = delivery.NodeAccountAnswer
|
||||
|
||||
// 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.
|
||||
@@ -354,19 +401,17 @@ func (v *Vault) NodeAccount(caller string, a NodeAccountArgs) (NodeAccountAnswer
|
||||
} 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)
|
||||
to, err := sealingRaw(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)
|
||||
}
|
||||
@@ -378,12 +423,18 @@ func (v *Vault) NodeAccount(caller string, a NodeAccountArgs) (NodeAccountAnswer
|
||||
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)
|
||||
var acct struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &acct); err != nil || acct.Password == "" {
|
||||
return NodeAccountAnswer{}, fmt.Errorf("%s holds no password to answer", secret)
|
||||
}
|
||||
sealed, err := operatorSeal(to, []byte(acct.Password))
|
||||
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
|
||||
return NodeAccountAnswer{Node: a.Node, Login: user, Account: sealed}, nil
|
||||
}
|
||||
|
||||
// mintNodeAccount makes a joining node's account: its value kept as the login's own secret, its hash in bus-accounts, and
|
||||
@@ -436,7 +487,7 @@ func (v *Vault) accountValue(node, user string) (value, password string, err 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}
|
||||
out := NodeAccountAnswer{Node: node, Login: 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)
|
||||
}
|
||||
@@ -468,6 +519,28 @@ func (v *Vault) retireNodeAccount(caller, node, user, secret string, was *Accoun
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sealingRaw takes the key a machine presents to be sealed to, as raw X25519 in base64 or SPKI PEM, and gives it raw in
|
||||
// base64: the form the node-engine's sealing key opens an anonymous box with.
|
||||
func sealingRaw(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 raw, err := base64.StdEncoding.DecodeString(key); err == nil && len(raw) == 32 {
|
||||
return key, nil
|
||||
}
|
||||
block, _ := pem.Decode([]byte(key))
|
||||
if block == nil {
|
||||
return "", errors.New("the sealing key presented is neither a raw X25519 key in base64 nor SPKI PEM")
|
||||
}
|
||||
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
pub, ok := parsed.(*ecdh.PublicKey)
|
||||
if err != nil || !ok || pub.Curve() != ecdh.X25519() {
|
||||
return "", errors.New("the sealing key presented is not an X25519 key")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(pub.Bytes()), 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)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -12,14 +17,34 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// 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
|
||||
acts map[string][]string
|
||||
}
|
||||
|
||||
func (l *loginsState) Acts(node string) ([]string, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return slices.Clone(l.acts[node]), nil
|
||||
}
|
||||
|
||||
func (l *loginsState) RecordAct(n NodeLogin, act string) error {
|
||||
if err := l.Put(n); err != nil {
|
||||
return err
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.acts == nil {
|
||||
l.acts = map[string][]string{}
|
||||
}
|
||||
l.acts[n.Node] = append(l.acts[n.Node], act)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *loginsState) Get(node string) (*NodeLogin, error) {
|
||||
@@ -53,6 +78,7 @@ func (l *loginsState) Delete(node string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.keys, node)
|
||||
delete(l.acts, node)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,125 +129,217 @@ func newEnrolling(t *testing.T) *enrolling {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *enrolling) act(id, by string) {
|
||||
e.acts[id] = HandActSeen{ID: id, By: by, At: time.Now().Add(time.Hour)}
|
||||
func (e *enrolling) act(id, by, node string) {
|
||||
e.acts[id] = HandActSeen{ID: id, By: by, At: time.Now().Add(time.Hour), Verb: "rotate-node", Args: []string{node}}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 774/S43/AC1-AC3, S44/AC1-AC3, S45/AC1 (novox/hq ADR 0587 §3, §5, §6, §8), review round 1: the vault's enrolment verbs
|
||||
// are called through their real tool handlers with the arguments mesh-enrolment sends, and each wire answer is decoded
|
||||
// into mesh-sdk's delivery types the holder decodes (EnrolmentTokenAnswer, NodeAccountAnswer, SignAnswer and the signed
|
||||
// parts of RedeemAnswer), with every field the holder reads filled. Only mesh-enrolment is answered; a token is issued
|
||||
// only with its first tunnel whole, its hash is bcrypt of the kept secret, `sealed` answers it sealed to the operator key
|
||||
// (a token whose Missing() is empty) and issues nothing; the account is the same for the same keys and opens with the
|
||||
// sealing key presented, as the node-engine opens it; a joining node's certificate is signed for the serving key presented
|
||||
// only when the keys have the digest node-account recorded; a retire and a drop take it all away.
|
||||
func TestTheVaultsEnrolmentAnswersAreMeshSDKsWireShapes(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)
|
||||
// new1 is placed with its private-network address, as mesh-enrolment writes it before it asks for the certificate.
|
||||
values := map[string][]byte{}
|
||||
for node, p := range map[string]delivery.Placement{"novox": {Node: "novox", Modules: []delivery.PlacedModule{{Module: "nats"}}},
|
||||
"g14": {Node: "g14"}, "new1": {Node: "new1", Decided: delivery.NodeDecided{Address: "10.77.0.9"}}} {
|
||||
p.V = delivery.Version
|
||||
kv, err := delivery.PlacementValues(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.NodeAccount(caller, NodeAccountArgs{Node: "new1"}); err == nil {
|
||||
t.Errorf("%q was answered a node account", caller)
|
||||
for k, raw := range kv {
|
||||
values[k] = raw
|
||||
}
|
||||
if _, err := e.Sign(caller, SignArgs{Kind: delivery.KindEnrolment, Node: "new1", Reply: "AAEC"}); err == nil {
|
||||
t.Errorf("%q had an enrolment signed", caller)
|
||||
_ = node
|
||||
}
|
||||
e.Placement = PlacementReader{Read: func(key string) ([]byte, error) { return values[key], nil },
|
||||
Keys: func() ([]string, error) {
|
||||
var out []string
|
||||
for k := range values {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}}
|
||||
defs := definitions(&Env{Vault: e.Vault})
|
||||
call := func(verb, caller string, args map[string]any) (json.RawMessage, error) {
|
||||
out, err := toolNamed(t, defs, Seat+"."+verb).run(caller, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
// 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 delivery.Token
|
||||
body, _ := base64.RawURLEncoding.DecodeString(text)
|
||||
const holder = "novox.mesh-enrolment"
|
||||
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)
|
||||
signerB64 := base64.StdEncoding.EncodeToString(signer.key.Public().(ed25519.PublicKey))
|
||||
tunnel := map[string]any{"key": "machine-tunnel-key", "address": "10.77.0.9", "range": "10.77.0.0/24",
|
||||
"hub_key": "hub-tunnel-key", "hub_endpoint": "203.0.113.1:51820"}
|
||||
without := func(field string) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, x := range tunnel {
|
||||
if k != field {
|
||||
out[k] = x
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
sealingKey, _ := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
sealingPub := base64.StdEncoding.EncodeToString(sealingKey.PublicKey().Bytes())
|
||||
identity, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
serving, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
keys := map[string]any{"public_key": base64.StdEncoding.EncodeToString(identity), "overlay_key": "overlay-pub",
|
||||
"sealing_key": sealingPub, "serving_key": base64.StdEncoding.EncodeToString(serving)}
|
||||
digest := delivery.KeysDigest(keys["public_key"].(string), "overlay-pub", sealingPub, keys["serving_key"].(string))
|
||||
with := func(base map[string]any, more map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, x := range base {
|
||||
out[k] = x
|
||||
}
|
||||
for k, x := range more {
|
||||
out[k] = x
|
||||
}
|
||||
return out
|
||||
}
|
||||
reply := delivery.RedeemAnswer{Node: "new1", Login: "node.new1", Account: "sealed", Certificate: "pem", Signer: signerB64}
|
||||
|
||||
// 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)
|
||||
var issued, sealed, dropped delivery.EnrolmentTokenAnswer
|
||||
var account, accountAgain, retired delivery.NodeAccountAnswer
|
||||
var cert, replySig delivery.SignAnswer
|
||||
var secret, accountHash string
|
||||
for _, c := range []struct {
|
||||
name, verb, caller string
|
||||
args map[string]any
|
||||
into any
|
||||
filled []string // the json fields the holder reads, which must not be empty
|
||||
refused string // a part of the refusal, when refused
|
||||
}{
|
||||
// Only the seat mesh-enrolment is answered: not the controller, a node, a person or a caller not proven.
|
||||
{"a node asks a token", "enrolment-token", "node.g14", map[string]any{"node": "new1", "tunnel": tunnel}, nil, nil, "mesh-enrolment"},
|
||||
{"the controller asks a token", "enrolment-token", "controller", map[string]any{"node": "new1", "tunnel": tunnel}, nil, nil, "mesh-enrolment"},
|
||||
{"a person asks an account", "node-account", "person.jochen", map[string]any{"node": "new1"}, nil, nil, "mesh-enrolment"},
|
||||
{"unproven asks an enrolment signed", "sign", "", map[string]any{"kind": delivery.KindEnrolment, "node": "new1", "reply": "AAEC"}, nil, nil, "proved"},
|
||||
{"a grants part asks an enrolment signed", "sign", "novox.mesh-grants", map[string]any{"kind": delivery.KindEnrolment, "node": "new1", "reply": "AAEC"}, nil, nil, "mesh-enrolment"},
|
||||
// Insight 2026-11-02: the first tunnel is whole at issue, or nothing is issued.
|
||||
{"no tunnel", "enrolment-token", holder, map[string]any{"node": "new1"}, nil, nil, "first tunnel"},
|
||||
{"a tunnel without the hub's key", "enrolment-token", holder, map[string]any{"node": "new1", "tunnel": without("hub_key")}, nil, nil, "hub's tunnel key"},
|
||||
{"a tunnel without the hub's endpoint", "enrolment-token", holder, map[string]any{"node": "new1", "tunnel": without("hub_endpoint")}, nil, nil, "where the hub's tunnel is dialled"},
|
||||
{"a tunnel without the address", "enrolment-token", holder, map[string]any{"node": "new1", "tunnel": without("address")}, nil, nil, "this machine's address"},
|
||||
{"a tunnel with a field it has not", "enrolment-token", holder, map[string]any{"node": "new1", "tunnel": with(tunnel, map[string]any{"port": 1})}, nil, nil, "not a first tunnel"},
|
||||
// Issue answers what the machine must trust; sealed and drop as the holder sends them, the switch as a word.
|
||||
{"issue", "enrolment-token", holder, map[string]any{"node": "new1", "expires": "2026-11-02T12:00:00Z", "tunnel": tunnel}, &issued, []string{"node", "broker", "fingerprint", "signer"}, ""},
|
||||
{"sealed", "enrolment-token", holder, map[string]any{"node": "new1", "sealed": "true"}, &sealed, []string{"node", "sealed"}, ""},
|
||||
// The account at the redeem, twice with the same keys; other keys and an adopted node's are refused.
|
||||
{"node-account", "node-account", holder, map[string]any{"node": "new1", "sealing_key": sealingPub, "keys_digest": digest}, &account, []string{"node", "login", "account"}, ""},
|
||||
{"node-account again", "node-account", holder, map[string]any{"node": "new1", "sealing_key": sealingPub, "keys_digest": digest}, &accountAgain, []string{"node", "login", "account"}, ""},
|
||||
{"node-account for other keys", "node-account", holder, map[string]any{"node": "new1", "sealing_key": sealingPub, "keys_digest": "sha256:other"}, nil, nil, "another set of keys"},
|
||||
{"node-account of an adopted node", "node-account", holder, map[string]any{"node": "g14", "sealing_key": sealingPub, "keys_digest": digest}, nil, nil, "never minted anew"},
|
||||
// The certificate for the serving key presented, checked against node-account's digest; the reply signed.
|
||||
{"a certificate without the keys", "sign", holder, map[string]any{"kind": delivery.KindNodeCertificate, "node": "new1", "serving_key": keys["serving_key"]}, nil, nil, "keys it presented"},
|
||||
{"a certificate for another serving key", "sign", holder, with(keys, map[string]any{"kind": delivery.KindNodeCertificate, "node": "new1", "serving_key": base64.StdEncoding.EncodeToString(identity)}), nil, nil, "not the ones its account was made for"},
|
||||
{"a certificate", "sign", holder, with(keys, map[string]any{"kind": delivery.KindNodeCertificate, "node": "new1"}), &cert, []string{"kind", "node", "certificate", "authority", "signer", "digest"}, ""},
|
||||
{"the reply signed", "sign", holder, map[string]any{"kind": delivery.KindEnrolment, "node": "new1", "reply": base64.StdEncoding.EncodeToString(reply.SignedBytes())}, &replySig, []string{"kind", "node", "signature", "signer", "digest"}, ""},
|
||||
{"a reply of a node with no token", "sign", holder, map[string]any{"kind": delivery.KindEnrolment, "node": "g14", "reply": "AAEC"}, nil, nil, "no enrolment token"},
|
||||
// A revoke: the account retired, the token and its hash dropped.
|
||||
{"retire", "node-account", holder, map[string]any{"node": "new1", "retire": "true"}, &retired, []string{"node", "retired"}, ""},
|
||||
{"drop", "enrolment-token", holder, map[string]any{"node": "new1", "drop": "true"}, &dropped, []string{"node", "dropped"}, ""},
|
||||
} {
|
||||
raw, err := call(c.verb, c.caller, c.args)
|
||||
if c.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||
t.Errorf("%s: answered %s, %v; want a refusal saying %q", c.name, raw, err, c.refused)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, c.into); err != nil {
|
||||
t.Fatalf("%s: %s does not decode as mesh-sdk's shape: %v", c.name, raw, err)
|
||||
}
|
||||
var fields map[string]any
|
||||
back, _ := json.Marshal(c.into)
|
||||
_ = json.Unmarshal(back, &fields)
|
||||
for _, f := range c.filled {
|
||||
if x, ok := fields[f]; !ok || x == "" || x == false {
|
||||
t.Errorf("%s: the wire answer %s leaves %q empty", c.name, raw, f)
|
||||
}
|
||||
}
|
||||
if c.name == "issue" {
|
||||
// What was kept: the secret, whose bcrypt hash is enrol.new1; listed, never handed out, never answered.
|
||||
rec, held, _ := e.Store.Read("enrol/new1/token")
|
||||
secret, _ = openAtRest(rec, e.Vault)
|
||||
key := e.accounts.keys["enrol.new1"]
|
||||
if !held || secret == "" || strings.Contains(string(raw), secret) || strings.Contains(strings.Join(e.bus.raw, " "), secret) ||
|
||||
key.Kept != KeptEnrolment || bcrypt.CompareHashAndPassword([]byte(key.Hash), []byte(secret)) != nil {
|
||||
t.Fatalf("the token is held %v, its hash %+v, or its secret is in an answer or an event: %s", held, key, raw)
|
||||
}
|
||||
if held, _ := e.EnrolmentsHeld(); len(held) != 1 || held[0].User != "enrol.new1" {
|
||||
t.Errorf("secret_holders names %+v", held)
|
||||
}
|
||||
}
|
||||
if c.name == "node-account" {
|
||||
accountHash = e.accounts.keys["node.new1"].Hash
|
||||
if _, err := e.Get(holder, "enrol/new1/token", pemOf(t, mustPub(t))); err == nil {
|
||||
t.Error("an enrolment token was handed out by get")
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
if issued.Broker != "tls://10.77.0.1:4222" || issued.Fingerprint != "sha256:bus" || issued.Signer != signerB64 || issued.Sealed != "" {
|
||||
t.Errorf("issue answered %+v", issued)
|
||||
}
|
||||
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)
|
||||
// sealed issued nothing new: the token it seals is the one whose hash is enrol.new1, with its tunnel, and usable whole.
|
||||
tok, err := delivery.ParseToken(openOperator(t, e.operatorPublic, e.operatorPrivate, sealed.Sealed))
|
||||
if err != nil || len(tok.Missing()) != 0 || tok.Node != "new1" || tok.Broker != issued.Broker || tok.Tunnel == nil ||
|
||||
tok.Tunnel.HubEndpoint != "203.0.113.1:51820" || base64.StdEncoding.EncodeToString(tok.Signer) != signerB64 ||
|
||||
tok.Secret != secret {
|
||||
t.Errorf("the sealed token is %+v, %v", tok, err)
|
||||
}
|
||||
if nl := e.logins.keys["new1"]; nl.Login != "node.new1" || nl.Rotating != "" || nl.Stage != "" {
|
||||
t.Errorf("node-logins new1 is %+v", nl)
|
||||
// The account opens with the sealing key presented, as the node-engine opens it, and is the same both times.
|
||||
open := func(a delivery.NodeAccountAnswer) string {
|
||||
blob, _ := base64.StdEncoding.DecodeString(a.Account)
|
||||
var pub, priv [32]byte
|
||||
copy(pub[:], sealingKey.PublicKey().Bytes())
|
||||
copy(priv[:], sealingKey.Bytes())
|
||||
out, _ := box.OpenAnonymous(nil, blob, &pub, &priv)
|
||||
return string(out)
|
||||
}
|
||||
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 p := open(account); p == "" || p != open(accountAgain) || account.Login != "node.new1" ||
|
||||
bcrypt.CompareHashAndPassword([]byte(accountHash), []byte(p)) != nil {
|
||||
t.Errorf("the account is %+v / %+v", account, accountAgain)
|
||||
}
|
||||
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)")
|
||||
// The certificate certifies the serving key presented, for new1's internal name.
|
||||
block, _ := pem.Decode([]byte(cert.Certificate))
|
||||
if block == nil {
|
||||
t.Fatalf("the certificate is %q", cert.Certificate)
|
||||
}
|
||||
parsed, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil || !ed25519.PublicKey(serving).Equal(parsed.PublicKey) {
|
||||
t.Errorf("the certificate is of another key: %v", err)
|
||||
}
|
||||
// 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)
|
||||
sig, _ := base64.StdEncoding.DecodeString(replySig.Signature)
|
||||
if !ed25519.Verify(signer.key.Public().(ed25519.PublicKey), append([]byte(ctx), reply.SignedBytes()...), sig) {
|
||||
t.Error("the redeem answer's signature does not verify under the enrolment context")
|
||||
}
|
||||
// After the retire and the drop nothing of the enrolment remains.
|
||||
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)
|
||||
for _, name := range []string{"own/new1/mesh-host/node.new1", "enrol/new1/token", "enrol/new1/tunnel"} {
|
||||
if _, held, _ := e.Store.Read(name); held {
|
||||
t.Errorf("%s remains after the retire and the drop", name)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -240,9 +358,11 @@ func TestANodesLoginRotatesOneAtATimeAndEndsWithTheListWithoutTheOldOne(t *testi
|
||||
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"} {
|
||||
e.acts["theirs"] = HandActSeen{ID: "theirs", By: "person.other", At: time.Now().Add(time.Hour), Verb: "rotate-node", Args: []string{"g14"}}
|
||||
e.acts["old"] = HandActSeen{ID: "old", By: "person.jochen", At: time.Unix(0, 0), Verb: "rotate-node", Args: []string{"g14"}}
|
||||
e.acts["elsewhere"] = HandActSeen{ID: "elsewhere", By: "person.jochen", At: time.Now().Add(time.Hour), Verb: "rotate-node", Args: []string{"ace"}}
|
||||
e.acts["restart"] = HandActSeen{ID: "restart", By: "person.jochen", At: time.Now().Add(time.Hour), Verb: "restart", Args: []string{"g14"}}
|
||||
for _, act := range []string{"", "missing", "theirs", "old", "elsewhere", "restart"} {
|
||||
if _, err := ask("g14", act); err == nil {
|
||||
t.Errorf("act %q warranted a rotation", act)
|
||||
}
|
||||
@@ -250,9 +370,9 @@ func TestANodesLoginRotatesOneAtATimeAndEndsWithTheListWithoutTheOldOne(t *testi
|
||||
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")
|
||||
e.act("a1", "person.jochen", "g14")
|
||||
e.act("a2", "person.jochen", "ace")
|
||||
e.act("a3", "person.jochen", "novox")
|
||||
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)
|
||||
@@ -331,6 +451,13 @@ func TestANodesLoginRotatesOneAtATimeAndEndsWithTheListWithoutTheOldOne(t *testi
|
||||
if _, err := e.AbandonRotation("person.jochen", "g14"); err == nil {
|
||||
t.Error("a finished rotation was abandoned")
|
||||
}
|
||||
// Blocker 5 (ADR 0583 §2): the act is recorded in g14's key and warrants no second rotation.
|
||||
if acts, _ := e.logins.Acts("g14"); !slices.Equal(acts, []string{"a1"}) {
|
||||
t.Errorf("g14's key records the acts %v", acts)
|
||||
}
|
||||
if _, err := ask("g14", "a1"); err == nil || !strings.Contains(err.Error(), "already") {
|
||||
t.Errorf("a used hand act warranted another rotation: %v", err)
|
||||
}
|
||||
// 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 != "" {
|
||||
|
||||
@@ -5,9 +5,15 @@ package main
|
||||
// runtime proved.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// EnrolmentHeld is one enrolment token the vault holds, as secret_holders lists it: never the token or its hash.
|
||||
@@ -50,19 +56,26 @@ func enrolmentTools(env *Env) []toolDef {
|
||||
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"}},
|
||||
"§8). Issue (the default): takes the machine's first tunnel, makes the one-time secret, keeps both with the operator " +
|
||||
"copy, writes the secret's hash as the bus user enrol.<node>, and answers the bus's address, its certificate's " +
|
||||
"fingerprint and the vault's signing key, never the token. sealed: 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"),
|
||||
"tunnel": map[string]any{"type": "object", "description": "the machine's first tunnel at issue: hub_key, " +
|
||||
"hub_endpoint, address (and key, range)"},
|
||||
"sealed": 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
|
||||
}
|
||||
tunnel, err := tunnelArg(a["tunnel"])
|
||||
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"])})
|
||||
Tunnel: tunnel, Sealed: isTrue(a["sealed"]), 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 " +
|
||||
@@ -142,3 +155,31 @@ func enrolmentTools(env *Env) []toolDef {
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// tunnelArg reads enrolment-token's `tunnel`, an object or its JSON as a string, as mesh-sdk's TokenTunnel; nil when
|
||||
// absent.
|
||||
func tunnelArg(v any) (*delivery.TokenTunnel, error) {
|
||||
var raw []byte
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw = []byte(t)
|
||||
default:
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = b
|
||||
}
|
||||
var tt delivery.TokenTunnel
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&tt); err != nil {
|
||||
return nil, fmt.Errorf("`tunnel` is not a first tunnel (hub_key, hub_endpoint, address, key, range): %w", err)
|
||||
}
|
||||
return &tt, nil
|
||||
}
|
||||
|
||||
@@ -52,10 +52,14 @@ const (
|
||||
// and while a rotation runs the other login with its stage. Never a value or a hash.
|
||||
type NodeLogin = delivery.NodeLogin
|
||||
|
||||
// NodeLoginStore is the vault's state `node-logins`, by node.
|
||||
// NodeLoginStore is the vault's state `node-logins`, by node. Beside mesh-sdk's NodeLogin each key keeps `acts`, the ids
|
||||
// of the hand acts that warranted the node's rotations (ADR 0583 §2: every rotation is recorded), which Put keeps.
|
||||
type NodeLoginStore interface {
|
||||
Get(node string) (*NodeLogin, error)
|
||||
Put(n NodeLogin) error
|
||||
// Acts are the hand acts recorded in node's key; RecordAct writes n with act added to them.
|
||||
Acts(node string) ([]string, error)
|
||||
RecordAct(n NodeLogin, act string) error
|
||||
Delete(node string) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
@@ -75,8 +79,32 @@ func (busNodeLogins) Get(node string) (*NodeLogin, error) {
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (busNodeLogins) Put(n NodeLogin) error {
|
||||
raw, err := delivery.EncodeNodeLogin(n)
|
||||
func (b busNodeLogins) Put(n NodeLogin) error {
|
||||
acts, err := b.Acts(n.Node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.write(n, acts)
|
||||
}
|
||||
|
||||
func (busNodeLogins) Acts(node string) ([]string, error) {
|
||||
e, err := stdio.State(NodeLoginsState).Get(node)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return actsOf(e.Value)
|
||||
}
|
||||
|
||||
func (b busNodeLogins) RecordAct(n NodeLogin, act string) error {
|
||||
acts, err := b.Acts(n.Node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.write(n, append(acts, act))
|
||||
}
|
||||
|
||||
func (busNodeLogins) write(n NodeLogin, acts []string) error {
|
||||
raw, err := encodeLoginKey(n, acts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,15 +112,43 @@ func (busNodeLogins) Put(n NodeLogin) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// encodeLoginKey is n through mesh-sdk's codec with the hand acts that warranted its rotations beside it.
|
||||
func encodeLoginKey(n NodeLogin, acts []string) ([]byte, error) {
|
||||
raw, err := delivery.EncodeNodeLogin(n)
|
||||
if err != nil || len(acts) == 0 {
|
||||
return raw, err
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m["acts"] = acts
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// actsOf are the hand acts a key of node-logins records.
|
||||
func actsOf(raw []byte) ([]string, error) {
|
||||
var k struct {
|
||||
Acts []string `json:"acts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &k); err != nil {
|
||||
return nil, fmt.Errorf("a key of %s is not readable: %w", NodeLoginsState, err)
|
||||
}
|
||||
return k.Acts, nil
|
||||
}
|
||||
|
||||
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.
|
||||
// HandActSeen is a hand act as the vault reads it back from mesh-hand-acts by its id (mesh-sdk's handacts.Act): who
|
||||
// recorded it, when, and what it was (its verb and arguments).
|
||||
type HandActSeen struct {
|
||||
ID string `json:"id"`
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
ID string `json:"id"`
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
Verb string `json:"verb"`
|
||||
Args []string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// ActReader reads a hand act back by its id: nil when mesh-hand-acts holds none.
|
||||
@@ -169,8 +225,8 @@ func loginNode(user string) (string, bool) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// another caller, older than a day, or an act that is not rotate-node of this node.
|
||||
func (v *Vault) checkAct(caller, node, 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)")
|
||||
}
|
||||
@@ -188,6 +244,10 @@ func (v *Vault) checkAct(caller, id string) error {
|
||||
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)
|
||||
case act.Verb != delivery.RotateNodeVerb && !strings.HasSuffix(act.Verb, "."+delivery.RotateNodeVerb):
|
||||
return fmt.Errorf("the hand act %s is %q, not %s: it warrants no rotation", id, act.Verb, delivery.RotateNodeVerb)
|
||||
case !slices.Contains(act.Args, node):
|
||||
return fmt.Errorf("the hand act %s is a rotation of %s, not of %s", id, strings.Join(act.Args, " "), node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -245,7 +305,7 @@ func (v *Vault) RotateNode(caller string, a RotateNodeArgs) (RotateNodeAnswer, e
|
||||
if strings.TrimSpace(a.Why) == "" {
|
||||
return RotateNodeAnswer{}, errors.New("rotate-node says why (`why`)")
|
||||
}
|
||||
if err := v.checkAct(caller, a.Act); err != nil {
|
||||
if err := v.checkAct(caller, a.Node, a.Act); err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
}
|
||||
if v.Accounts == nil {
|
||||
@@ -277,6 +337,9 @@ func (v *Vault) RotateNode(caller string, a RotateNodeArgs) (RotateNodeAnswer, e
|
||||
return RotateNodeAnswer{}, fmt.Errorf("%s's rotation is asked already, by %s, and waits its turn", a.Node, q.By)
|
||||
}
|
||||
}
|
||||
if err := v.recordAct(a.Node, a.Act); err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
}
|
||||
running, err := v.rotatingNodes()
|
||||
if err != nil {
|
||||
return RotateNodeAnswer{}, err
|
||||
@@ -303,6 +366,36 @@ func (v *Vault) RotateNode(caller string, a RotateNodeArgs) (RotateNodeAnswer, e
|
||||
return RotateNodeAnswer{Node: a.Node, Login: nl.Login, Rotating: nl.Rotating, Stage: nl.Stage, Revision: revision}, nil
|
||||
}
|
||||
|
||||
// recordAct keeps the hand act in node's key of node-logins, refusing one already used: an act warrants one rotation
|
||||
// (ADR 0583 §2). Under nr.mu.
|
||||
func (v *Vault) recordAct(node, act string) error {
|
||||
if v.Logins == nil {
|
||||
return errors.New("the vault has no state " + NodeLoginsState + " to record the rotation in")
|
||||
}
|
||||
used, err := v.Logins.Acts(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Contains(used, act) {
|
||||
return fmt.Errorf("the hand act %s warranted a rotation of %s already: record a new one", act, node)
|
||||
}
|
||||
nl, err := v.getLogin(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nl == nil {
|
||||
cur, err := v.currentLogin(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nl = &NodeLogin{Node: node, Login: cur, Since: v.Now().UTC()}
|
||||
}
|
||||
if err := v.Logins.RecordAct(*nl, act); err != nil {
|
||||
return fmt.Errorf("the hand act %s could not be recorded in %s %s: %w", act, NodeLoginsState, node, err)
|
||||
}
|
||||
return 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)
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestTheVaultWholeRotatesANodesLoginWithAStandInEngine(t *testing.T) {
|
||||
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
|
||||
return map[string]any{"acts": []HandActSeen{{ID: "act-1", By: "person.jochen", At: time.Now(), Verb: "rotate-node", Args: []string{"g14"}}}}, nil
|
||||
}
|
||||
go p.serve(stdout)
|
||||
|
||||
|
||||
@@ -240,6 +240,15 @@ type SignArgs struct {
|
||||
Kind string
|
||||
Node string
|
||||
Reply string
|
||||
// Keys are, for a joining node's certificate asked by mesh-enrolment, the public keys the machine presented at its
|
||||
// redeem (delivery.Redeem's): the serving key is certified, the four are checked against the digest node-account
|
||||
// recorded.
|
||||
Keys PresentedKeys
|
||||
}
|
||||
|
||||
// PresentedKeys are the public keys a joining machine presented at its redeem, as delivery.Redeem names them.
|
||||
type PresentedKeys struct {
|
||||
Public, Overlay, Sealing, Serving string
|
||||
}
|
||||
|
||||
// Sign answers caller's `sign`. The caller is the one the runtime proved; who may ask which kind is the vault's rule,
|
||||
@@ -252,7 +261,7 @@ 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.signRedeemingCertificate(caller, a.Node, a.Keys)
|
||||
}
|
||||
return v.signNodeCertificate(caller, a.Node)
|
||||
case delivery.KindEnrolment:
|
||||
@@ -281,12 +290,39 @@ func (v *Vault) redeeming(node string) error {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// (ADR 0587 §6). The machine has written no record yet, so the serving key is the one it presented at the redeem, which
|
||||
// counts only when the four keys presented have the digest node-account recorded for its account; the name is its
|
||||
// placement's, which mesh-enrolment writes before it asks.
|
||||
func (v *Vault) signRedeemingCertificate(caller, node string, keys PresentedKeys) (delivery.SignAnswer, error) {
|
||||
if err := v.redeeming(node); err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
return v.signCertificateFor(caller, node)
|
||||
if v.Accounts == nil {
|
||||
return delivery.SignAnswer{}, errors.New("the vault has no state of bus accounts, so it cannot read the keys a joining node presented")
|
||||
}
|
||||
acct, err := v.Accounts.Get(NodeEnginePrefix + node)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, NodeEnginePrefix+node, err)
|
||||
}
|
||||
if acct == nil || acct.Kept != KeptNode || acct.KeysDigest == "" {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s has no account made at its redeem (node-account), so no keys are recorded to certify", node)
|
||||
}
|
||||
if keys.Serving == "" || keys.Public == "" || keys.Sealing == "" {
|
||||
return delivery.SignAnswer{}, errors.New("a joining node's certificate is asked with the keys it presented at the redeem " +
|
||||
"(`public_key`, `overlay_key`, `sealing_key`, `serving_key`), which are checked against node-account's digest")
|
||||
}
|
||||
if got := delivery.KeysDigest(keys.Public, keys.Overlay, keys.Sealing, keys.Serving); got != acct.KeysDigest {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("the keys presented for %s are not the ones its account was made for: nothing is signed", node)
|
||||
}
|
||||
pub, err := base64.StdEncoding.DecodeString(keys.Serving)
|
||||
if err != nil || len(pub) != ed25519.PublicKeySize {
|
||||
return delivery.SignAnswer{}, fmt.Errorf("%s's serving key is not an ed25519 public key in base64", node)
|
||||
}
|
||||
name, err := v.internalName(node)
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
return v.signCertificateWith(caller, node, name, ed25519.PublicKey(pub))
|
||||
}
|
||||
|
||||
// signEnrolment signs the bytes of an enrolment reply under the enrolment context, for a node that is joining; the
|
||||
@@ -339,6 +375,11 @@ func (v *Vault) signCertificateFor(caller, node string) (delivery.SignAnswer, er
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
}
|
||||
return v.signCertificateWith(caller, node, name, serving)
|
||||
}
|
||||
|
||||
// signCertificateWith signs the certificate of name for serving, for node.
|
||||
func (v *Vault) signCertificateWith(caller, node, name string, serving ed25519.PublicKey) (delivery.SignAnswer, error) {
|
||||
k, err := v.keys()
|
||||
if err != nil {
|
||||
return delivery.SignAnswer{}, err
|
||||
|
||||
@@ -586,14 +586,20 @@ func definitions(env *Env) []toolDef {
|
||||
"adoption's record (ADR 0561 §5). Answers the certificate, the authority, the signer's fingerprint and the " +
|
||||
"digest, and says signed. Never a key.",
|
||||
Input: map[string]any{"kind": str("node-certificate or enrolment"),
|
||||
"node": str("the machine, for a node certificate: the caller's own (left out, the caller's)"),
|
||||
"reply": str("the enrolment reply's bytes in base64, for an enrolment")},
|
||||
"node": str("the machine, for a node certificate: the caller's own (left out, the caller's)"),
|
||||
"reply": str("the enrolment reply's bytes in base64, for an enrolment"),
|
||||
"public_key": str("a joining node's identity key, as presented at its redeem (mesh-enrolment alone)"),
|
||||
"overlay_key": str("a joining node's overlay key, as presented at its redeem"),
|
||||
"sealing_key": str("a joining node's sealing key, as presented at its redeem"),
|
||||
"serving_key": str("a joining node's serving key to certify, as presented at its redeem")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Sign(caller, SignArgs{Kind: arg(a, "kind"), Node: arg(a, "node"), Reply: arg(a, "reply")})
|
||||
return v.Sign(caller, SignArgs{Kind: arg(a, "kind"), Node: arg(a, "node"), Reply: arg(a, "reply"),
|
||||
Keys: PresentedKeys{Public: arg(a, "public_key"), Overlay: arg(a, "overlay_key"), Sealing: arg(a, "sealing_key"),
|
||||
Serving: arg(a, "serving_key")}})
|
||||
}},
|
||||
{Name: "vault_signers",
|
||||
Description: "The vault's signing key and authority (novox/hq ADR 0550 §2): whether each is held with its operator " +
|
||||
|
||||
Reference in New Issue
Block a user