diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index 9107ca7..40bd2cc 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -203,7 +203,13 @@ func newRig(t *testing.T) *rig { r.said = append(r.said, said{event, id, raw}) return nil }, - AddNode: func(a AddNode) error { r.added = append(r.added, a); return r.placeErr }, + AddNode: func(a AddNode) error { + r.added = append(r.added, a) + if r.placeErr == nil { // placement writes the node as the assigner does on the warrant + r.rd["mesh-delivery-assigner.placement"][a.Node+".node"] = `{"v":1,"node":"` + a.Node + `","decided":{"address":"` + a.Address + `"},"modules":[]}` + } + return r.placeErr + }, Warrant: func(ask string) error { r.warr = append(r.warr, ask); return nil }, Act: func(what, why, cause string) error { r.acts = append(r.acts, what); return nil }, Loaded: func(string) (bool, error) { return r.loaded, nil }, diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go new file mode 100644 index 0000000..d714e4f --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go @@ -0,0 +1,166 @@ +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" +) + +// RedeemArgs are what a joining node-engine says at redeem (ADR 0587 §6): the public halves of the keys it made where it +// runs, and a proof, signed with its identity key, that it holds the private half of the first. +type RedeemArgs struct { + Node string `json:"node"` + PublicKey []byte `json:"public_key"` + OverlayKey string `json:"overlay_key,omitempty"` + SealingKey string `json:"sealing_key,omitempty"` + ServingKey string `json:"serving_key,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Proof []byte `json:"proof"` +} + +// Keys are the public halves presented. +func (a RedeemArgs) Keys() NodeKeys { + return NodeKeys{Public: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey} +} + +// RedeemProof is the bytes a node signs with its identity key when it redeems: the node's name and every key it presents, so a +// proof cannot be moved to another request or another node. The layout is mesh-host's EnrolProof (internal/link/enrol.go), +// with the node's name in the place of the token's secret, which this seat never holds. +func RedeemProof(node string, k NodeKeys) []byte { + return []byte("novox-mesh-enrol\x00" + node + "\x00" + base64.StdEncoding.EncodeToString(k.Public) + + "\x00" + k.Overlay + "\x00" + k.Sealing + "\x00" + k.Serving) +} + +// RedeemReply is what the vault signs: the node's account sealed to its sealing key, the signed certificate for its internal +// name and the address the operator approved. +type RedeemReply struct { + Node string `json:"node"` + Address string `json:"address"` + Account json.RawMessage `json:"account"` + Certificate json.RawMessage `json:"certificate"` +} + +// RedeemAnswer is what redeem answers: the reply's bytes and the vault's signature over them. The node-engine refuses an +// answer not signed by the key its token carries. +type RedeemAnswer struct { + Reply []byte `json:"reply"` + Signed json.RawMessage `json:"signed"` +} + +// Redeem is the joining node-engine's one call (ADR 0587 §5, §6). It answers only a caller the runtime proves to be +// enrol., for a key at stage live (or at redeeming with the same keys' digest), checks the proof against the identity key +// presented, and then, each step read back and none repeated that is done: spends the token (the key to redeeming, by +// compare-and-set, with no expiry), asks the vault for the node's account and the certificate, writes the node into placement, +// waits for the bus node to load node., and answers the whole, signed by the vault. Called again with the same keys it +// finishes what is not done and answers the same. +func (h *Holder) Redeem(caller string, a RedeemArgs) (RedeemAnswer, error) { + if err := CheckNode(a.Node); err != nil { + return RedeemAnswer{}, err + } + if caller != EnrolUser(a.Node) { + return RedeemAnswer{}, fmt.Errorf("only %s may redeem the enrolment of %s, and the runtime proved the caller to be %q", EnrolUser(a.Node), a.Node, caller) + } + keys := a.Keys() + if len(a.PublicKey) != ed25519.PublicKeySize || !ed25519.Verify(a.PublicKey, RedeemProof(a.Node, keys), a.Proof) { + return RedeemAnswer{}, errors.New("the proof does not verify against the identity key presented: the enrolment is not spent") + } + digest := keys.Digest() + e, err := h.spend(a.Node, digest) + if err != nil { + return RedeemAnswer{}, err + } + account, err := h.Vault.Account(e.Node, a.SealingKey, digest) + if err != nil { + return RedeemAnswer{}, fmt.Errorf("the vault did not give the account of %s (call redeem again with the same keys): %w", e.Node, err) + } + cert, err := h.Vault.SignCertificate(e.Node, a.ServingKey) + if err != nil { + return RedeemAnswer{}, fmt.Errorf("the vault did not sign the certificate of %s (call redeem again with the same keys): %w", e.Node, err) + } + if err := h.place(e, a, keys); err != nil { + return RedeemAnswer{}, err + } + if h.Loaded != nil { + loaded, err := h.Loaded(NodeUser(e.Node)) + if err != nil { + return RedeemAnswer{}, fmt.Errorf("whether the bus node loaded %s cannot be read (call redeem again with the same keys): %w", NodeUser(e.Node), err) + } + if !loaded { + return RedeemAnswer{}, fmt.Errorf("the bus node has not loaded %s yet (call redeem again with the same keys)", NodeUser(e.Node)) + } + } + reply, err := json.Marshal(RedeemReply{Node: e.Node, Address: e.Address, Account: account, Certificate: cert}) + if err != nil { + return RedeemAnswer{}, err + } + signed, err := h.Vault.SignReply(e.Node, reply) + if err != nil { + return RedeemAnswer{}, fmt.Errorf("the vault did not sign the answer (call redeem again with the same keys): %w", err) + } + return RedeemAnswer{Reply: reply, Signed: signed}, nil +} + +// spend moves the key from live to redeeming with the presented keys' digest, by compare-and-set, before its expiry; at +// redeeming it only checks the digest. A key that moved meanwhile is read again once. +func (h *Holder) spend(node, digest string) (Enrolment, error) { + for attempt := 0; attempt < 3; attempt++ { + row, found, err := h.Store.Get(NodeKey(node)) + if err != nil { + return Enrolment{}, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err) + } + if !found { + return Enrolment{}, fmt.Errorf("no enrolment of %s is open: it was never issued, has ended or has expired", node) + } + e, err := decodeEnrolment(row.Value) + if err != nil { + return Enrolment{}, err + } + switch e.Stage { + case StageRedeeming: + if e.KeysDigest != digest { + return Enrolment{}, fmt.Errorf("the enrolment of %s was redeemed with other keys: a token is spent once", node) + } + return e, nil + case StageAsked: + return Enrolment{}, fmt.Errorf("the enrolment of %s waits for the operator's yes: no token exists yet", node) + } + if !h.now().Before(e.Expires) { + return Enrolment{}, fmt.Errorf("the enrolment of %s expired at %s", node, e.Expires.UTC().Format(time.RFC3339)) + } + e.Stage, e.KeysDigest = StageRedeeming, digest + if _, err := h.Store.Update(NodeKey(node), e, row.Revision); err != nil { + if errors.Is(err, ErrChanged) { + continue + } + return Enrolment{}, fmt.Errorf("the enrolment of %s cannot be spent: %w", node, err) + } + // The move to redeeming rewrites both keys without an expiry, so a redeem under way never loses its key (§5). + if _, err := h.Store.Put(AddressKey(e.Address), Reservation{V: 1, Node: node}, 0); err != nil { + return Enrolment{}, fmt.Errorf("the address key %s cannot be kept (call redeem again with the same keys): %w", e.Address, err) + } + return e, nil + } + return Enrolment{}, fmt.Errorf("the enrolment of %s kept changing: call redeem again", node) +} + +// place writes the node into placement with the issue's warrant, unless placement holds it at the approved address already. +func (h *Holder) place(e Enrolment, a RedeemArgs, keys NodeKeys) error { + placements, err := h.placements() + if err != nil { + return fmt.Errorf("placement cannot be read (call redeem again with the same keys): %w", err) + } + if p, ok := placements[e.Node]; ok && p.Decided.Address == e.Address { + return nil + } + if h.AddNode == nil { + return errors.New("no route to mesh-placement") + } + if err := h.AddNode(AddNode{Node: e.Node, Address: e.Address, Why: "enrolment of " + e.Node + ": " + e.Why, Keys: keys, + Warrant: Warrant{Ask: e.Ask, Node: e.Node, Address: e.Address}}); err != nil { + return fmt.Errorf("%w (call redeem again with the same keys)", err) + } + return nil +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go new file mode 100644 index 0000000..0d3e2e5 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "testing" + "time" +) + +// engineEnrolProof is mesh-host's EnrolProof (internal/link/enrol.go:112-114), copied: the bytes a node-engine signs. The +// holder's RedeemProof must be the same bytes with the node's name in the token's place. +func engineEnrolProof(secret string, public []byte, overlay, sealing, serving string) []byte { + return []byte("novox-mesh-enrol\x00" + secret + "\x00" + base64.StdEncoding.EncodeToString(public) + + "\x00" + overlay + "\x00" + sealing + "\x00" + serving) +} + +// redeemArgs are a node's redeem call with a proof signed by the key seeded, over the keys given. +func redeemArgs(node string, seed byte, serving string) RedeemArgs { + priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize)) + a := RedeemArgs{Node: node, PublicKey: priv.Public().(ed25519.PublicKey), OverlayKey: "overlay-" + node, SealingKey: "sealing-" + node, ServingKey: serving} + a.Proof = ed25519.Sign(priv, RedeemProof(node, a.Keys())) + return a +} + +// 774/S56/AC1: a redeem from another caller, with a bad proof, after the expiry, or with other keys than the first, is +// refused (one row each), and a good one spends the token, writes the node into placement on the issue's warrant and +// answers once the bus node loaded the account. +func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) { + good := redeemArgs("ace", 1, "serving-a") + bad := good + bad.Proof = append([]byte{}, good.Proof...) + bad.Proof[0] ^= 1 + if !bytes.Equal(RedeemProof("ace", good.Keys()), engineEnrolProof("ace", good.PublicKey, good.OverlayKey, good.SealingKey, good.ServingKey)) { + t.Fatal("the proof's bytes are not the engine's") + } + cases := []struct { + name string + caller string + args RedeemArgs + setup func(r *rig) + want string + }{ + {"another caller", "enrol.g14", good, nil, "only enrol.ace may redeem"}, + {"a bad proof", "enrol.ace", bad, nil, "proof does not verify"}, + {"after the expiry", "enrol.ace", good, func(r *rig) { r.now = r.now.Add(2 * time.Hour) }, "expired at"}, + {"other keys than the first", "enrol.ace", redeemArgs("ace", 2, "serving-b"), func(r *rig) { + e, _ := r.enrolment("ace") + e.Stage, e.KeysDigest = StageRedeeming, good.Keys().Digest() + _, _ = r.st.Put(NodeKey("ace"), e, 0) + }, "spent once"}, + {"before the operator's yes", "enrol.ace", good, func(r *rig) { + e, _ := r.enrolment("ace") + e.Stage = StageAsked + _, _ = r.st.Put(NodeKey("ace"), e, 0) + }, "waits for the operator's yes"}, + {"no enrolment", "enrol.novox", redeemArgs("novox", 3, "s"), nil, "no enrolment of novox is open"}, + } + for _, c := range cases { + r := newRig(t) + r.seed("ace", "10.9.0.2", StageLive) + if c.setup != nil { + c.setup(r) + } + _, err := r.h.Redeem(c.caller, c.args) + if err == nil { + t.Errorf("%s: redeemed", c.name) + continue + } + mustContain(t, err.Error(), c.want) + if len(r.vault.calls) != 0 || len(r.added) != 0 { + t.Errorf("%s: the vault %v or placement %v was called", c.name, r.vault.calls, r.added) + } + } + r := newRig(t) + r.seed("ace", "10.9.0.2", StageLive) + r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey("10.9.0.2")] = time.Hour, time.Hour + ans, err := r.h.Redeem("enrol.ace", good) + if err != nil { + t.Fatal(err) + } + e, _ := r.enrolment("ace") + if e.Stage != StageRedeeming || e.KeysDigest != good.Keys().Digest() || r.st.ttl[NodeKey("ace")] != 0 || r.st.ttl[AddressKey("10.9.0.2")] != 0 { + t.Errorf("spent %+v, ttl %v/%v", e, r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey("10.9.0.2")]) + } + if len(r.added) != 1 || r.added[0].Warrant != (Warrant{Ask: "ask-ace", Node: "ace", Address: "10.9.0.2"}) || len(ans.Reply) == 0 || len(ans.Signed) == 0 { + t.Errorf("placement %+v, answer %+v", r.added, ans) + } + r.loaded = false + if _, err := r.h.Redeem("enrol.ace", good); err == nil { + t.Error("answered before the bus node loaded the account") + } +} + +// 774/S56/AC2: a redeem stopped after the vault's call, or after the placement call, called again with the same keys, +// finishes and answers the same, and the vault minted once (it is asked for the same account). +func TestARedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) { + good := redeemArgs("ace", 1, "serving-a") + whole := newRig(t) + whole.seed("ace", "10.9.0.2", StageLive) + want, err := whole.h.Redeem("enrol.ace", good) + if err != nil { + t.Fatal(err) + } + for _, stop := range []string{"certificate ace", "sign ace"} { + r := newRig(t) + r.seed("ace", "10.9.0.2", StageLive) + r.vault.fail[stop] = true + if _, err := r.h.Redeem("enrol.ace", good); err == nil { + t.Fatalf("%s: no error", stop) + } + got, err := r.h.Redeem("enrol.ace", good) + if err != nil || !bytes.Equal(got.Reply, want.Reply) || string(got.Signed) != string(want.Signed) { + t.Errorf("%s: %v, answer %+v, want %+v", stop, err, got, want) + } + if stop == "sign ace" && len(r.added) != 1 { + t.Errorf("placement was written %d times", len(r.added)) + } + } + // Placement that already holds the node at the approved address is not written again. + r := newRig(t) + r.seed("ace", "10.9.0.2", StageRedeeming) + e, _ := r.enrolment("ace") + e.KeysDigest = good.Keys().Digest() + _, _ = r.st.Put(NodeKey("ace"), e, 0) + r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","decided":{"address":"10.9.0.2"},"modules":[]}` + if _, err := r.h.Redeem("enrol.ace", good); err != nil || len(r.added) != 0 { + t.Errorf("%v, placement written %d times", err, len(r.added)) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go index 787a9d2..3777b71 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "strings" "time" @@ -12,6 +13,7 @@ import ( const ( IssueTool = Seat + ".issue" AgainTool = Seat + ".again" + RedeemTool = Seat + ".redeem" TokensTool = Seat + ".tokens" ) @@ -65,6 +67,26 @@ func Tools(h *Holder) []stdio.Tool { } return h.Issue(stdio.CallerOf(a), arg(a, "node"), arg(a, "why"), lasts) }}, + {Name: RedeemTool, + Description: "The joining machine's one call (novox/hq ADR 0587 §5, §6), answered only for a caller the runtime proves to be " + + "enrol.: its node-engine presents the public halves of the keys it made where it runs and a proof signed with its " + + "identity key. The token is spent (once, with those keys), the vault gives the node's account and signs its certificate, " + + "the node is written into placement on the issue's warrant, and the answer is given once the bus node loaded node., " + + "signed by the vault. Called again with the same keys it finishes what is not done and answers the same.", + Input: obj(map[string]any{"node": str("the machine's name"), "public_key": str("its identity key, base64"), + "overlay_key": str("its private-network key"), "sealing_key": str("its sealing key, base64"), + "serving_key": str("its serving key"), "proof": str("the proof, base64")}, "node", "public_key", "proof"), + Run: func(a map[string]any) (any, error) { + var args RedeemArgs + raw, err := json.Marshal(a) + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("the redeem call does not read: %w", err) + } + return h.Redeem(stdio.CallerOf(a), args) + }}, {Name: AgainTool, Description: "Ask the operator again about an enrolment still at stage asked whose ask the router no longer holds open " + "(novox/hq ADR 0587 §8): a new ask for the same node, address and expiry. Nothing more: a redeem stopped midway is finished " +