From 09824b483993e609e076c5db9b34a97022464875 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 01:03:19 +0100 Subject: [PATCH] Vault: answer the enrolment verbs in mesh-sdk's wire shapes, certify the serving key presented, record each rotation's hand act MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../mesh-vault/cmd/mesh-vault/enrolment.go | 159 ++++++--- .../cmd/mesh-vault/enrolment_test.go | 323 ++++++++++++------ .../cmd/mesh-vault/enrolment_tools.go | 55 ++- .../mesh-vault/cmd/mesh-vault/noderotation.go | 113 +++++- .../mesh-vault/rotation_integration_test.go | 2 +- modules/mesh-vault/cmd/mesh-vault/signing.go | 49 ++- modules/mesh-vault/cmd/mesh-vault/tools.go | 12 +- 7 files changed, 547 insertions(+), 166 deletions(-) diff --git a/modules/mesh-vault/cmd/mesh-vault/enrolment.go b/modules/mesh-vault/cmd/mesh-vault/enrolment.go index b9c92d03..17f47978 100644 --- a/modules/mesh-vault/cmd/mesh-vault/enrolment.go +++ b/modules/mesh-vault/cmd/mesh-vault/enrolment.go @@ -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.`. 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.` 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) diff --git a/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go b/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go index 1f52a26e..af6d1c15 100644 --- a/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go @@ -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 != "" { diff --git a/modules/mesh-vault/cmd/mesh-vault/enrolment_tools.go b/modules/mesh-vault/cmd/mesh-vault/enrolment_tools.go index d4705ac6..7b5c244d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/enrolment_tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/enrolment_tools.go @@ -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., 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., 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., 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 +} diff --git a/modules/mesh-vault/cmd/mesh-vault/noderotation.go b/modules/mesh-vault/cmd/mesh-vault/noderotation.go index 8666ceb5..f732f9fd 100644 --- a/modules/mesh-vault/cmd/mesh-vault/noderotation.go +++ b/modules/mesh-vault/cmd/mesh-vault/noderotation.go @@ -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) diff --git a/modules/mesh-vault/cmd/mesh-vault/rotation_integration_test.go b/modules/mesh-vault/cmd/mesh-vault/rotation_integration_test.go index b61a9127..3a80e078 100644 --- a/modules/mesh-vault/cmd/mesh-vault/rotation_integration_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/rotation_integration_test.go @@ -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) diff --git a/modules/mesh-vault/cmd/mesh-vault/signing.go b/modules/mesh-vault/cmd/mesh-vault/signing.go index 0eea9a27..705e91b5 100644 --- a/modules/mesh-vault/cmd/mesh-vault/signing.go +++ b/modules/mesh-vault/cmd/mesh-vault/signing.go @@ -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 diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index c9efe581..ce181856 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -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 " +